diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000..2fa2b720 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,10 @@ +# cargo-audit configuration +# Ignore advisories for transitive dependencies we can't control + +[advisories] +ignore = [ + # rsa: Marvin timing attack (RUSTSEC-2023-0071) + # Transitive via russh-keys -> ssh-key -> rsa + # Only used for RSA key parsing in SSH; no direct exposure + "RUSTSEC-2023-0071", +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e766865a..ab81678e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: uses: rustsec/audit-check@v2.0.0 with: token: ${{ secrets.GITHUB_TOKEN }} + ignore: RUSTSEC-2023-0071 - name: License check (cargo-deny) uses: EmbarkStudios/cargo-deny-action@v2 @@ -82,7 +83,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run tests - run: cargo test --features http_client + run: cargo test --features http_client,ssh - name: Run realfs tests run: cargo test --features realfs -p bashkit --test realfs_tests -p bashkit-cli @@ -107,7 +108,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Build examples - run: cargo build --examples --features "git,http_client" + run: cargo build --examples --features "git,http_client,ssh" - name: Run examples run: | @@ -122,6 +123,17 @@ jobs: cargo run --example realfs_readonly --features realfs cargo run --example realfs_readwrite --features realfs + # SSH mock tests (no network needed) + - name: Run ssh builtin tests (mock handler) + run: cargo test --features ssh -p bashkit --test ssh_builtin_tests + + # Real SSH connection — depends on external service, don't block CI + - name: Run ssh supabase.sh (real connection) + continue-on-error: true + run: | + cargo run --example ssh_supabase --features ssh + cargo test --features ssh -p bashkit --test ssh_supabase_tests -- --ignored + - name: Run realfs bash example run: | cargo build -p bashkit-cli --features realfs diff --git a/Cargo.toml b/Cargo.toml index 55aeeb19..3191e9e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,10 @@ schemars = "1" tracing = "0.1" tower = { version = "0.5", features = ["util"] } +# SSH client (for ssh/scp/sftp builtins) +russh = "0.52" +russh-keys = "0.49" + # Serial test execution serial_test = "3" diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index 4b9ba8d9..021250bb 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -41,6 +41,10 @@ chrono = { workspace = true } # HTTP client (for curl/wget) - optional, enabled with http_client feature reqwest = { workspace = true, optional = true } +# SSH client (for ssh/scp/sftp) - optional, enabled with ssh feature +russh = { workspace = true, optional = true } +russh-keys = { workspace = true, optional = true } + # Fault injection for testing (optional) fail = { workspace = true, optional = true } @@ -86,6 +90,9 @@ logging = ["tracing"] # Phase 2 will add gix dependency for remote operations # Usage: cargo build --features git git = [] +# Enable ssh/scp/sftp builtins for remote command execution and file transfer +# Usage: cargo build --features ssh +ssh = ["russh", "russh-keys"] # Enable ScriptedTool: compose ToolDef+callback pairs into a single Tool # Usage: cargo build --features scripted_tool scripted_tool = [] @@ -125,6 +132,10 @@ required-features = ["http_client"] name = "git_workflow" required-features = ["git"] +[[example]] +name = "ssh_supabase" +required-features = ["ssh"] + [[example]] name = "scripted_tool" required-features = ["scripted_tool"] diff --git a/crates/bashkit/docs/ssh.md b/crates/bashkit/docs/ssh.md new file mode 100644 index 00000000..15ca240f --- /dev/null +++ b/crates/bashkit/docs/ssh.md @@ -0,0 +1,77 @@ +# SSH Support + +Bashkit provides `ssh`, `scp`, and `sftp` builtins for remote command execution +and file transfer over SSH. The default transport uses [russh](https://crates.io/crates/russh). + +**See also:** [`specs/015-ssh-support.md`][spec] + +## Quick Start + +```rust,no_run +use bashkit::{Bash, SshConfig}; + +# #[tokio::main] +# async fn main() -> bashkit::Result<()> { +let mut bash = Bash::builder() + .ssh(SshConfig::new().allow("supabase.sh")) + .build(); + +let result = bash.exec("ssh supabase.sh").await?; +# Ok(()) +# } +``` + +## Usage + +```bash +# Remote command +ssh host.example.com 'uname -a' + +# Heredoc +ssh host.example.com <<'EOF' +psql -c 'SELECT version()' +EOF + +# Shell session (TUI services like supabase.sh) +ssh supabase.sh + +# SCP +scp local.txt host.example.com:/remote/path.txt +scp host.example.com:/remote/file.txt local.txt + +# SFTP (heredoc/pipe mode) +sftp host.example.com <<'EOF' +put /tmp/data.csv /var/import/data.csv +get /var/export/report.csv /tmp/report.csv +ls /var/import +EOF +``` + +## Configuration + +```rust,no_run +use bashkit::SshConfig; +use std::time::Duration; + +let config = SshConfig::new() + .allow("*.supabase.co") // wildcard subdomain + .allow("bastion.example.com") // exact host + .allow_port(2222) // additional port (default: 22 only) + .default_user("deploy") // when no user@ prefix + .timeout(Duration::from_secs(30)) // connection timeout + .max_response_bytes(10_000_000) // max output size + .max_sessions(5); // concurrent session limit +``` + +## Authentication + +Tried in order: none (public services) → public key (`-i` flag or `default_private_key()`) → password (`default_password()`). + +## Security + +- Default-deny host allowlist with glob patterns and port restrictions +- Keys read from VFS only, never host `~/.ssh/` +- Remote paths shell-escaped (TM-SSH-008) +- Response size and session count limits + +[spec]: https://github.com/everruns/bashkit/blob/main/specs/015-ssh-support.md diff --git a/crates/bashkit/examples/ssh_supabase.rs b/crates/bashkit/examples/ssh_supabase.rs new file mode 100644 index 00000000..e88c0fbb --- /dev/null +++ b/crates/bashkit/examples/ssh_supabase.rs @@ -0,0 +1,29 @@ +//! SSH Supabase example — `ssh supabase.sh` +//! +//! Connects to Supabase's public SSH service, exactly like running +//! `ssh supabase.sh` in a terminal. No credentials needed. +//! +//! Run with: cargo run --example ssh_supabase --features ssh + +use bashkit::{Bash, SshConfig}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("=== Bashkit: ssh supabase.sh ===\n"); + + let mut bash = Bash::builder() + .ssh(SshConfig::new().allow("supabase.sh")) + .build(); + + println!("$ ssh supabase.sh\n"); + let result = bash.exec("ssh supabase.sh").await?; + + print!("{}", result.stdout); + if !result.stderr.is_empty() { + eprint!("{}", result.stderr); + } + + println!("\nexit code: {}", result.exit_code); + println!("\n=== Done ==="); + Ok(()) +} diff --git a/crates/bashkit/src/builtins/archive.rs b/crates/bashkit/src/builtins/archive.rs index 4765b007..1606a98d 100644 --- a/crates/bashkit/src/builtins/archive.rs +++ b/crates/bashkit/src/builtins/archive.rs @@ -895,6 +895,8 @@ impl Builtin for Gunzip { http_client: ctx.http_client, #[cfg(feature = "git")] git_client: ctx.git_client, + #[cfg(feature = "ssh")] + ssh_client: ctx.ssh_client, shell: ctx.shell, }; @@ -950,6 +952,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -970,6 +974,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1005,6 +1011,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1028,6 +1036,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1065,6 +1075,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1090,6 +1102,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1119,6 +1133,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1154,6 +1170,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1173,6 +1191,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1205,6 +1225,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1236,6 +1258,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1254,6 +1278,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1286,6 +1312,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1312,6 +1340,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1341,6 +1371,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1368,6 +1400,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1400,6 +1434,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Gzip.execute(ctx).await.unwrap(); @@ -1417,6 +1453,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/awk.rs b/crates/bashkit/src/builtins/awk.rs index cf326f64..2132e5aa 100644 --- a/crates/bashkit/src/builtins/awk.rs +++ b/crates/bashkit/src/builtins/awk.rs @@ -3518,6 +3518,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -4096,6 +4098,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -4323,6 +4327,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/checksum.rs b/crates/bashkit/src/builtins/checksum.rs index 2c9fafe2..0d0e2e03 100644 --- a/crates/bashkit/src/builtins/checksum.rs +++ b/crates/bashkit/src/builtins/checksum.rs @@ -112,6 +112,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/clear.rs b/crates/bashkit/src/builtins/clear.rs index 4a6fb73f..93b21ac9 100644 --- a/crates/bashkit/src/builtins/clear.rs +++ b/crates/bashkit/src/builtins/clear.rs @@ -46,6 +46,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = Clear.execute(ctx).await.expect("clear failed"); diff --git a/crates/bashkit/src/builtins/column.rs b/crates/bashkit/src/builtins/column.rs index 40955898..309f9503 100644 --- a/crates/bashkit/src/builtins/column.rs +++ b/crates/bashkit/src/builtins/column.rs @@ -209,6 +209,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/comm.rs b/crates/bashkit/src/builtins/comm.rs index 308f66c9..50157379 100644 --- a/crates/bashkit/src/builtins/comm.rs +++ b/crates/bashkit/src/builtins/comm.rs @@ -195,6 +195,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/compgen.rs b/crates/bashkit/src/builtins/compgen.rs index bc4e0a24..3f68b78d 100644 --- a/crates/bashkit/src/builtins/compgen.rs +++ b/crates/bashkit/src/builtins/compgen.rs @@ -262,6 +262,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Compgen.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/csv.rs b/crates/bashkit/src/builtins/csv.rs index 4638209c..7aec1fc3 100644 --- a/crates/bashkit/src/builtins/csv.rs +++ b/crates/bashkit/src/builtins/csv.rs @@ -402,6 +402,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Csv.execute(ctx).await.unwrap() @@ -427,6 +429,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Csv.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/curl.rs b/crates/bashkit/src/builtins/curl.rs index 9c56c2db..12df62fa 100644 --- a/crates/bashkit/src/builtins/curl.rs +++ b/crates/bashkit/src/builtins/curl.rs @@ -1098,6 +1098,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1122,6 +1124,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1184,6 +1188,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/cuttr.rs b/crates/bashkit/src/builtins/cuttr.rs index a7f898b5..4f98fb43 100644 --- a/crates/bashkit/src/builtins/cuttr.rs +++ b/crates/bashkit/src/builtins/cuttr.rs @@ -540,6 +540,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -564,6 +566,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/date.rs b/crates/bashkit/src/builtins/date.rs index 9996c9d5..812eb124 100644 --- a/crates/bashkit/src/builtins/date.rs +++ b/crates/bashkit/src/builtins/date.rs @@ -459,6 +459,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/diff.rs b/crates/bashkit/src/builtins/diff.rs index a0818973..5fa8a315 100644 --- a/crates/bashkit/src/builtins/diff.rs +++ b/crates/bashkit/src/builtins/diff.rs @@ -416,6 +416,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/dotenv.rs b/crates/bashkit/src/builtins/dotenv.rs index afa131f8..f0b51423 100644 --- a/crates/bashkit/src/builtins/dotenv.rs +++ b/crates/bashkit/src/builtins/dotenv.rs @@ -182,6 +182,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Dotenv.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/environ.rs b/crates/bashkit/src/builtins/environ.rs index e48d34ec..508117e6 100644 --- a/crates/bashkit/src/builtins/environ.rs +++ b/crates/bashkit/src/builtins/environ.rs @@ -342,6 +342,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -369,6 +371,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -394,6 +398,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -424,6 +430,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -453,6 +461,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -480,6 +490,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -507,6 +519,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -533,6 +547,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -559,6 +575,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -586,6 +604,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -610,6 +630,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -634,6 +656,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -659,6 +683,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/envsubst.rs b/crates/bashkit/src/builtins/envsubst.rs index 12fd12be..805d77ee 100644 --- a/crates/bashkit/src/builtins/envsubst.rs +++ b/crates/bashkit/src/builtins/envsubst.rs @@ -196,6 +196,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Envsubst.execute(ctx).await.expect("envsubst failed") diff --git a/crates/bashkit/src/builtins/expand.rs b/crates/bashkit/src/builtins/expand.rs index 45e60e43..60d9a2f9 100644 --- a/crates/bashkit/src/builtins/expand.rs +++ b/crates/bashkit/src/builtins/expand.rs @@ -263,6 +263,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Expand.execute(ctx).await.expect("expand failed") @@ -285,6 +287,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Unexpand.execute(ctx).await.expect("unexpand failed") diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index b8597b4f..a68d5544 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -770,6 +770,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -795,6 +797,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -820,6 +824,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -850,6 +856,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -875,6 +883,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -904,6 +914,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -937,6 +949,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -968,6 +982,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/fold.rs b/crates/bashkit/src/builtins/fold.rs index c3831df6..4181d958 100644 --- a/crates/bashkit/src/builtins/fold.rs +++ b/crates/bashkit/src/builtins/fold.rs @@ -159,6 +159,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Fold.execute(ctx).await.expect("fold failed") diff --git a/crates/bashkit/src/builtins/glob_cmd.rs b/crates/bashkit/src/builtins/glob_cmd.rs index 3df3f9fb..b27f3bae 100644 --- a/crates/bashkit/src/builtins/glob_cmd.rs +++ b/crates/bashkit/src/builtins/glob_cmd.rs @@ -175,6 +175,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; GlobCmd.execute(ctx).await.unwrap() @@ -196,6 +198,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; GlobCmd.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/grep.rs b/crates/bashkit/src/builtins/grep.rs index b1838ea8..5af89459 100644 --- a/crates/bashkit/src/builtins/grep.rs +++ b/crates/bashkit/src/builtins/grep.rs @@ -875,6 +875,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1052,6 +1054,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1091,6 +1095,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1155,6 +1161,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1198,6 +1206,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1238,6 +1248,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1272,6 +1284,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1304,6 +1318,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1336,6 +1352,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1389,6 +1407,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/headtail.rs b/crates/bashkit/src/builtins/headtail.rs index fae91daa..46ce561e 100644 --- a/crates/bashkit/src/builtins/headtail.rs +++ b/crates/bashkit/src/builtins/headtail.rs @@ -282,6 +282,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -306,6 +308,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/hextools.rs b/crates/bashkit/src/builtins/hextools.rs index e86e8f2b..b1d7deff 100644 --- a/crates/bashkit/src/builtins/hextools.rs +++ b/crates/bashkit/src/builtins/hextools.rs @@ -597,6 +597,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -621,6 +623,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -645,6 +649,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -674,6 +680,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/iconv.rs b/crates/bashkit/src/builtins/iconv.rs index 4693439a..7105bdc8 100644 --- a/crates/bashkit/src/builtins/iconv.rs +++ b/crates/bashkit/src/builtins/iconv.rs @@ -317,6 +317,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Iconv.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/inspect.rs b/crates/bashkit/src/builtins/inspect.rs index 84eeba93..ac851347 100644 --- a/crates/bashkit/src/builtins/inspect.rs +++ b/crates/bashkit/src/builtins/inspect.rs @@ -441,6 +441,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -466,6 +468,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -498,6 +502,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -526,6 +532,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -553,6 +561,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -584,6 +594,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -612,6 +624,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -639,6 +653,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -668,6 +684,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -697,6 +715,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -722,6 +742,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -747,6 +769,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -778,6 +802,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -808,6 +834,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -837,6 +865,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -867,6 +897,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -894,6 +926,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -919,6 +953,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -944,6 +980,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -969,6 +1007,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/join.rs b/crates/bashkit/src/builtins/join.rs index 9597b591..a1be442a 100644 --- a/crates/bashkit/src/builtins/join.rs +++ b/crates/bashkit/src/builtins/join.rs @@ -168,6 +168,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Join.execute(ctx).await.expect("join failed") diff --git a/crates/bashkit/src/builtins/jq.rs b/crates/bashkit/src/builtins/jq.rs index 64a15f8e..e2595704 100644 --- a/crates/bashkit/src/builtins/jq.rs +++ b/crates/bashkit/src/builtins/jq.rs @@ -713,6 +713,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -778,6 +780,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -803,6 +807,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -827,6 +833,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -991,6 +999,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1102,6 +1112,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1128,6 +1140,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1274,6 +1288,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = jq.execute(ctx).await.unwrap(); @@ -1299,6 +1315,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = jq.execute(ctx).await.unwrap(); @@ -1405,6 +1423,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1447,6 +1467,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/json.rs b/crates/bashkit/src/builtins/json.rs index e27d9bcc..f925c34e 100644 --- a/crates/bashkit/src/builtins/json.rs +++ b/crates/bashkit/src/builtins/json.rs @@ -327,6 +327,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Json.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/ls.rs b/crates/bashkit/src/builtins/ls.rs index 69cea049..7a590dfb 100644 --- a/crates/bashkit/src/builtins/ls.rs +++ b/crates/bashkit/src/builtins/ls.rs @@ -1055,6 +1055,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1088,6 +1090,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1122,6 +1126,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1143,6 +1149,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1173,6 +1181,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1200,6 +1210,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1225,6 +1237,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1258,6 +1272,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1289,6 +1305,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1322,6 +1340,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1355,6 +1375,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1388,6 +1410,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1419,6 +1443,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1450,6 +1476,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1484,6 +1512,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1510,6 +1540,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1535,6 +1567,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1560,6 +1594,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1606,6 +1642,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1669,6 +1707,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1716,6 +1756,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1770,6 +1812,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1788,6 +1832,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1836,6 +1882,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1876,6 +1924,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1921,6 +1971,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1968,6 +2020,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -1994,6 +2048,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2029,6 +2085,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2064,6 +2122,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2089,6 +2149,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2118,6 +2180,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2148,6 +2212,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2173,6 +2239,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2202,6 +2270,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2229,6 +2299,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2256,6 +2328,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2325,6 +2399,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2419,6 +2495,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2504,6 +2582,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2554,6 +2634,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2595,6 +2677,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2631,6 +2715,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2665,6 +2751,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2703,6 +2791,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2741,6 +2831,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2792,6 +2884,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2824,6 +2918,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2857,6 +2953,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2892,6 +2990,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2929,6 +3029,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2962,6 +3064,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -2996,6 +3100,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -3042,6 +3148,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -3217,6 +3325,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -3257,6 +3367,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -3298,6 +3410,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index b5b99624..8c821892 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -110,6 +110,9 @@ mod zip_cmd; #[cfg(feature = "git")] mod git; +#[cfg(feature = "ssh")] +mod ssh; + #[cfg(feature = "python")] mod python; @@ -203,6 +206,9 @@ pub use zip_cmd::{Unzip, Zip}; #[cfg(feature = "git")] pub use git::Git; +#[cfg(feature = "ssh")] +pub use ssh::{Scp, Sftp, Ssh}; + #[cfg(feature = "python")] pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits}; @@ -396,6 +402,14 @@ pub struct Context<'a> { #[cfg(feature = "git")] pub git_client: Option<&'a crate::git::GitClient>, + /// SSH client for ssh/scp/sftp operations. + /// + /// Only available when the `ssh` feature is enabled and + /// an [`SshConfig`](crate::SshConfig) is configured via + /// [`BashBuilder::ssh`](crate::BashBuilder::ssh). + #[cfg(feature = "ssh")] + pub ssh_client: Option<&'a crate::ssh::SshClient>, + /// Direct access to interpreter shell state. /// /// Provides internal builtins with: @@ -435,6 +449,8 @@ impl<'a> Context<'a> { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, } } diff --git a/crates/bashkit/src/builtins/nl.rs b/crates/bashkit/src/builtins/nl.rs index 7c91be8c..03ef3985 100644 --- a/crates/bashkit/src/builtins/nl.rs +++ b/crates/bashkit/src/builtins/nl.rs @@ -202,6 +202,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -235,6 +237,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/paste.rs b/crates/bashkit/src/builtins/paste.rs index c5a65fb3..197a57ba 100644 --- a/crates/bashkit/src/builtins/paste.rs +++ b/crates/bashkit/src/builtins/paste.rs @@ -164,6 +164,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -197,6 +199,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/patch.rs b/crates/bashkit/src/builtins/patch.rs index 3bf2647f..5b75d957 100644 --- a/crates/bashkit/src/builtins/patch.rs +++ b/crates/bashkit/src/builtins/patch.rs @@ -398,6 +398,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/path.rs b/crates/bashkit/src/builtins/path.rs index 0eacc352..4eab3897 100644 --- a/crates/bashkit/src/builtins/path.rs +++ b/crates/bashkit/src/builtins/path.rs @@ -308,6 +308,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -332,6 +334,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -436,6 +440,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/pipeline.rs b/crates/bashkit/src/builtins/pipeline.rs index 08885288..8d9e3203 100644 --- a/crates/bashkit/src/builtins/pipeline.rs +++ b/crates/bashkit/src/builtins/pipeline.rs @@ -342,6 +342,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -367,6 +369,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -392,6 +396,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -427,6 +433,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -453,6 +461,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -478,6 +488,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -503,6 +515,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -528,6 +542,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -559,6 +575,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -594,6 +612,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -622,6 +642,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -656,6 +678,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -683,6 +707,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -708,6 +734,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -735,6 +763,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -761,6 +791,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -787,6 +819,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -812,6 +846,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/rg.rs b/crates/bashkit/src/builtins/rg.rs index 5219ace9..e5a0d265 100644 --- a/crates/bashkit/src/builtins/rg.rs +++ b/crates/bashkit/src/builtins/rg.rs @@ -303,6 +303,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -472,6 +474,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = Rg.execute(ctx).await; diff --git a/crates/bashkit/src/builtins/sed.rs b/crates/bashkit/src/builtins/sed.rs index 0dac0794..eb4aadca 100644 --- a/crates/bashkit/src/builtins/sed.rs +++ b/crates/bashkit/src/builtins/sed.rs @@ -1010,6 +1010,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/semver.rs b/crates/bashkit/src/builtins/semver.rs index 46b06cf0..d21eaea5 100644 --- a/crates/bashkit/src/builtins/semver.rs +++ b/crates/bashkit/src/builtins/semver.rs @@ -234,6 +234,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Semver.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/sleep.rs b/crates/bashkit/src/builtins/sleep.rs index c0a4c01a..f8ccaaa7 100644 --- a/crates/bashkit/src/builtins/sleep.rs +++ b/crates/bashkit/src/builtins/sleep.rs @@ -82,6 +82,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/sortuniq.rs b/crates/bashkit/src/builtins/sortuniq.rs index b658420c..d0000cdb 100644 --- a/crates/bashkit/src/builtins/sortuniq.rs +++ b/crates/bashkit/src/builtins/sortuniq.rs @@ -757,6 +757,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -781,6 +783,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/split.rs b/crates/bashkit/src/builtins/split.rs index e220ff44..1800643f 100644 --- a/crates/bashkit/src/builtins/split.rs +++ b/crates/bashkit/src/builtins/split.rs @@ -180,6 +180,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Split.execute(ctx).await.expect("split failed") diff --git a/crates/bashkit/src/builtins/ssh.rs b/crates/bashkit/src/builtins/ssh.rs new file mode 100644 index 00000000..619348d5 --- /dev/null +++ b/crates/bashkit/src/builtins/ssh.rs @@ -0,0 +1,700 @@ +//! SSH, SCP, and SFTP builtins. +//! +//! Provides remote command execution and file transfer via SSH. +//! Requires the `ssh` feature and configuration via `Bash::builder().ssh()`. +//! +//! # Security +//! +//! - Host validated against allowlist before every operation (TM-SSH-001) +//! - Keys read from VFS only, never host filesystem (TM-SSH-002) +//! - Response size enforced by SshClient (TM-SSH-004) + +use async_trait::async_trait; + +use super::{Context, resolve_path}; +use crate::interpreter::ExecResult; + +// ── SSH builtin ────────────────────────────────────────────────────────── + +/// SSH builtin: execute commands on remote hosts. +/// +/// # Usage +/// +/// ```text +/// ssh [options] [user@]host [command...] +/// ``` +/// +/// # Options +/// +/// - `-p port` — Remote port (default: 22) +/// - `-i keyfile` — Identity file (private key from VFS) +/// - `-o option` — Ignored (compatibility) +/// - `-q` — Quiet mode (suppress warnings) +/// - `-v` — Verbose mode +pub struct Ssh; + +#[async_trait] +impl super::Builtin for Ssh { + async fn execute(&self, ctx: Context<'_>) -> crate::Result { + #[cfg(feature = "ssh")] + { + if let Some(ssh_client) = ctx.ssh_client { + return execute_ssh(ctx, ssh_client).await; + } + } + + // Suppress unused variable warning when feature is disabled + let _ = &ctx; + + Ok(ExecResult::err( + "ssh: not configured\n\ + Note: SSH requires the 'ssh' feature and configuration via Bash::builder().ssh()\n" + .to_string(), + 1, + )) + } +} + +#[cfg(feature = "ssh")] +async fn execute_ssh( + ctx: Context<'_>, + ssh_client: &crate::ssh::SshClient, +) -> crate::Result { + use crate::ssh::SshTarget; + + let mut port: Option = None; + let mut identity_file: Option = None; + let mut quiet = false; + let mut user_host: Option = None; + let mut command_args: Vec = Vec::new(); + let mut parsing_options = true; + + let mut i = 0; + while i < ctx.args.len() { + let arg = &ctx.args[i]; + + if parsing_options && arg.starts_with('-') { + match arg.as_str() { + "-p" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "ssh: option requires an argument -- 'p'\n".to_string(), + 1, + )); + } + port = Some(ctx.args[i].parse::().map_err(|_| { + crate::Error::Execution(format!("ssh: bad port '{}'\n", ctx.args[i])) + })?); + } + "-i" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "ssh: option requires an argument -- 'i'\n".to_string(), + 1, + )); + } + identity_file = Some(ctx.args[i].clone()); + } + "-o" => { + // Skip option=value (compatibility) + i += 1; + } + "-q" => quiet = true, + "-v" => {} // verbose: no-op for now + "--" => { + parsing_options = false; + } + _ => { + // Unknown option — treat as host if no host yet + if user_host.is_none() { + user_host = Some(arg.clone()); + parsing_options = false; + } else { + command_args.push(arg.clone()); + } + } + } + } else if user_host.is_none() { + user_host = Some(arg.clone()); + parsing_options = false; + } else { + command_args.push(arg.clone()); + } + i += 1; + } + + let user_host = match user_host { + Some(uh) => uh, + None => { + return Ok(ExecResult::err( + "usage: ssh [options] [user@]host [command...]\n".to_string(), + 1, + )); + } + }; + + // Parse user@host + let (user, host) = parse_user_host(&user_host, ssh_client.config()); + + let port = port.unwrap_or(ssh_client.config().default_port); + + // Read identity file from VFS if specified, else fall back to config key + let private_key = if let Some(ref key_path) = identity_file { + let abs = resolve_path(ctx.cwd, key_path); + let content = ctx + .fs + .read_file(&abs) + .await + .map_err(|e| crate::Error::Execution(format!("ssh: {}: {}\n", key_path, e)))?; + Some(String::from_utf8_lossy(&content).into_owned()) + } else { + ssh_client.config().default_private_key.clone() + }; + + // Fall back to config default password when no key is provided + let password = if private_key.is_none() { + ssh_client.config().default_password.clone() + } else { + None + }; + + let target = SshTarget { + host: host.clone(), + port, + user: user.clone(), + private_key, + password, + }; + + if command_args.is_empty() { + // No command: check if there's stdin (heredoc mode) + if let Some(stdin) = ctx.stdin { + if stdin.trim().is_empty() { + // Empty stdin + no command → open shell session + match ssh_client.shell(&target).await { + Ok(output) => Ok(build_result(output, quiet)), + Err(e) => Ok(ExecResult::err(format!("ssh: {}\n", e), 255)), + } + } else { + // Execute stdin as remote command + match ssh_client.exec(&target, stdin.trim()).await { + Ok(output) => Ok(build_result(output, quiet)), + Err(e) => Ok(ExecResult::err(format!("ssh: {}\n", e), 255)), + } + } + } else { + // No command, no stdin → open shell session + match ssh_client.shell(&target).await { + Ok(output) => Ok(build_result(output, quiet)), + Err(e) => Ok(ExecResult::err(format!("ssh: {}\n", e), 255)), + } + } + } else { + // Execute remote command + let command = command_args.join(" "); + match ssh_client.exec(&target, &command).await { + Ok(output) => Ok(build_result(output, quiet)), + Err(e) => Ok(ExecResult::err(format!("ssh: {}\n", e), 255)), + } + } +} + +// ── SCP builtin ────────────────────────────────────────────────────────── + +/// SCP builtin: copy files to/from remote hosts. +/// +/// # Usage +/// +/// ```text +/// scp [options] source... target +/// scp local_file [user@]host:remote_path +/// scp [user@]host:remote_path local_file +/// ``` +/// +/// # Options +/// +/// - `-P port` — Remote port +/// - `-i keyfile` — Identity file +/// - `-q` — Quiet mode +/// - `-r` — Recursive (directories) +pub struct Scp; + +#[async_trait] +impl super::Builtin for Scp { + async fn execute(&self, ctx: Context<'_>) -> crate::Result { + #[cfg(feature = "ssh")] + { + if let Some(ssh_client) = ctx.ssh_client { + return execute_scp(ctx, ssh_client).await; + } + } + + let _ = &ctx; + + Ok(ExecResult::err( + "scp: not configured\n\ + Note: SCP requires the 'ssh' feature and configuration via Bash::builder().ssh()\n" + .to_string(), + 1, + )) + } +} + +#[cfg(feature = "ssh")] +async fn execute_scp( + ctx: Context<'_>, + ssh_client: &crate::ssh::SshClient, +) -> crate::Result { + use crate::ssh::SshTarget; + + let mut port: Option = None; + let mut identity_file: Option = None; + let mut positional: Vec = Vec::new(); + + let mut i = 0; + while i < ctx.args.len() { + let arg = &ctx.args[i]; + match arg.as_str() { + "-P" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "scp: option requires an argument -- 'P'\n".to_string(), + 1, + )); + } + port = Some(ctx.args[i].parse::().map_err(|_| { + crate::Error::Execution(format!("scp: bad port '{}'\n", ctx.args[i])) + })?); + } + "-i" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "scp: option requires an argument -- 'i'\n".to_string(), + 1, + )); + } + identity_file = Some(ctx.args[i].clone()); + } + "-q" | "-r" => {} // quiet/recursive: accept but no-op for now + _ => positional.push(arg.clone()), + } + i += 1; + } + + if positional.len() < 2 { + return Ok(ExecResult::err( + "usage: scp [options] source target\n".to_string(), + 1, + )); + } + + let source = &positional[0]; + let target_str = &positional[1]; + + // Read identity file from VFS if specified, else fall back to config key + let private_key = if let Some(ref key_path) = identity_file { + let abs = resolve_path(ctx.cwd, key_path); + let content = ctx + .fs + .read_file(&abs) + .await + .map_err(|e| crate::Error::Execution(format!("scp: {}: {}\n", key_path, e)))?; + Some(String::from_utf8_lossy(&content).into_owned()) + } else { + ssh_client.config().default_private_key.clone() + }; + + let port = port.unwrap_or(ssh_client.config().default_port); + let password = if private_key.is_none() { + ssh_client.config().default_password.clone() + } else { + None + }; + + // Determine direction: upload or download + if let Some((remote_spec, remote_path)) = parse_remote_path(target_str) { + // Upload: scp local_file user@host:remote_path + let (user, host) = parse_user_host(&remote_spec, ssh_client.config()); + let local_path = resolve_path(ctx.cwd, source); + let content = ctx + .fs + .read_file(&local_path) + .await + .map_err(|e| crate::Error::Execution(format!("scp: {}: {}\n", source, e)))?; + + let ssh_target = SshTarget { + host, + port, + user, + private_key, + password: password.clone(), + }; + + match ssh_client + .upload(&ssh_target, &remote_path, &content, 0o644) + .await + { + Ok(()) => Ok(ExecResult::ok(String::new())), + Err(e) => Ok(ExecResult::err(format!("scp: {}\n", e), 1)), + } + } else if let Some((remote_spec, remote_path)) = parse_remote_path(source) { + // Download: scp user@host:remote_path local_file + let (user, host) = parse_user_host(&remote_spec, ssh_client.config()); + let local_path = resolve_path(ctx.cwd, target_str); + + let ssh_target = SshTarget { + host, + port, + user, + private_key, + password, + }; + + match ssh_client.download(&ssh_target, &remote_path).await { + Ok(data) => { + ctx.fs.write_file(&local_path, &data).await.map_err(|e| { + crate::Error::Execution(format!("scp: {}: {}\n", target_str, e)) + })?; + Ok(ExecResult::ok(String::new())) + } + Err(e) => Ok(ExecResult::err(format!("scp: {}\n", e), 1)), + } + } else { + Ok(ExecResult::err( + "scp: no remote host specified\n\ + usage: scp local_file [user@]host:path\n\ + scp [user@]host:path local_file\n" + .to_string(), + 1, + )) + } +} + +// ── SFTP builtin ───────────────────────────────────────────────────────── + +/// SFTP builtin: file transfer via SSH. +/// +/// In bashkit, SFTP works in non-interactive mode only (pipe/heredoc). +/// +/// # Usage +/// +/// ```text +/// sftp [options] [user@]host <) -> crate::Result { + #[cfg(feature = "ssh")] + { + if let Some(ssh_client) = ctx.ssh_client { + return execute_sftp(ctx, ssh_client).await; + } + } + + let _ = &ctx; + + Ok(ExecResult::err( + "sftp: not configured\n\ + Note: SFTP requires the 'ssh' feature and configuration via Bash::builder().ssh()\n" + .to_string(), + 1, + )) + } +} + +#[cfg(feature = "ssh")] +async fn execute_sftp( + ctx: Context<'_>, + ssh_client: &crate::ssh::SshClient, +) -> crate::Result { + use crate::ssh::SshTarget; + + let mut port: Option = None; + let mut identity_file: Option = None; + let mut user_host: Option = None; + + let mut i = 0; + while i < ctx.args.len() { + let arg = &ctx.args[i]; + match arg.as_str() { + "-P" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "sftp: option requires an argument -- 'P'\n".to_string(), + 1, + )); + } + port = Some(ctx.args[i].parse::().map_err(|_| { + crate::Error::Execution(format!("sftp: bad port '{}'\n", ctx.args[i])) + })?); + } + "-i" => { + i += 1; + if i >= ctx.args.len() { + return Ok(ExecResult::err( + "sftp: option requires an argument -- 'i'\n".to_string(), + 1, + )); + } + identity_file = Some(ctx.args[i].clone()); + } + _ => { + if user_host.is_none() { + user_host = Some(arg.clone()); + } + } + } + i += 1; + } + + let user_host = match user_host { + Some(uh) => uh, + None => { + return Ok(ExecResult::err( + "usage: sftp [options] [user@]host\n".to_string(), + 1, + )); + } + }; + + let stdin = match ctx.stdin { + Some(s) if !s.trim().is_empty() => s, + _ => { + return Ok(ExecResult::err( + "sftp: interactive mode not supported\n\ + hint: use heredoc or pipe commands to sftp\n" + .to_string(), + 1, + )); + } + }; + + let (user, host) = parse_user_host(&user_host, ssh_client.config()); + let port = port.unwrap_or(ssh_client.config().default_port); + + let private_key = if let Some(ref key_path) = identity_file { + let abs = resolve_path(ctx.cwd, key_path); + let content = ctx + .fs + .read_file(&abs) + .await + .map_err(|e| crate::Error::Execution(format!("sftp: {}: {}\n", key_path, e)))?; + Some(String::from_utf8_lossy(&content).into_owned()) + } else { + ssh_client.config().default_private_key.clone() + }; + + let password = if private_key.is_none() { + ssh_client.config().default_password.clone() + } else { + None + }; + + let target = SshTarget { + host, + port, + user, + private_key, + password, + }; + + let mut output = String::new(); + let mut last_exit = 0; + + for line in stdin.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let parts: Vec<&str> = line.splitn(3, ' ').collect(); + match parts.first().copied() { + Some("put") => { + if parts.len() < 3 { + output.push_str("sftp: put requires local_file and remote_path\n"); + last_exit = 1; + continue; + } + let local_path = resolve_path(ctx.cwd, parts[1]); + let content = match ctx.fs.read_file(&local_path).await { + Ok(c) => c, + Err(e) => { + output.push_str(&format!("sftp: {}: {}\n", parts[1], e)); + last_exit = 1; + continue; + } + }; + match ssh_client.upload(&target, parts[2], &content, 0o644).await { + Ok(()) => {} + Err(e) => { + output.push_str(&format!("sftp: put: {}\n", e)); + last_exit = 1; + } + } + } + Some("get") => { + if parts.len() < 3 { + output.push_str("sftp: get requires remote_path and local_file\n"); + last_exit = 1; + continue; + } + match ssh_client.download(&target, parts[1]).await { + Ok(data) => { + let local_path = resolve_path(ctx.cwd, parts[2]); + if let Err(e) = ctx.fs.write_file(&local_path, &data).await { + output.push_str(&format!("sftp: {}: {}\n", parts[2], e)); + last_exit = 1; + } + } + Err(e) => { + output.push_str(&format!("sftp: get: {}\n", e)); + last_exit = 1; + } + } + } + Some("ls") => { + let path = parts.get(1).copied().unwrap_or("."); + let cmd = format!("ls -la {}", path); + match ssh_client.exec(&target, &cmd).await { + Ok(result) => { + output.push_str(&result.stdout); + if !result.stderr.is_empty() { + output.push_str(&result.stderr); + } + } + Err(e) => { + output.push_str(&format!("sftp: ls: {}\n", e)); + last_exit = 1; + } + } + } + Some(cmd) => { + output.push_str(&format!("sftp: unsupported command '{}'\n", cmd)); + last_exit = 1; + } + None => {} + } + } + + if last_exit == 0 { + Ok(ExecResult::ok(output)) + } else { + Ok(ExecResult::err(output, last_exit)) + } +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/// Parse `user@host` into (user, host). Falls back to config default user. +#[cfg(feature = "ssh")] +fn parse_user_host(spec: &str, config: &crate::ssh::SshConfig) -> (String, String) { + if let Some(at_pos) = spec.find('@') { + let user = spec[..at_pos].to_string(); + let host = spec[at_pos + 1..].to_string(); + (user, host) + } else { + let user = config + .default_user + .clone() + .unwrap_or_else(|| "root".to_string()); + (user, spec.to_string()) + } +} + +/// Parse `[user@]host:path` into (user_host_part, path). +/// Returns None if there's no `:` separator. +#[cfg(feature = "ssh")] +fn parse_remote_path(spec: &str) -> Option<(String, String)> { + // Don't match Windows-style paths like C:\... + // A remote spec has : after hostname, not after a single letter + if let Some(colon_pos) = spec.find(':') { + // Ensure it's not a drive letter (single char before colon) + if colon_pos > 1 || !spec.as_bytes()[0].is_ascii_alphabetic() { + let remote_spec = spec[..colon_pos].to_string(); + let path = spec[colon_pos + 1..].to_string(); + return Some((remote_spec, path)); + } + } + None +} + +/// Build an ExecResult from SSH output. +#[cfg(feature = "ssh")] +fn build_result(output: crate::ssh::SshOutput, _quiet: bool) -> ExecResult { + if output.exit_code == 0 { + let mut result = ExecResult::ok(output.stdout); + if !output.stderr.is_empty() { + result.stderr = output.stderr; + } + result + } else { + let mut result = ExecResult::err(output.stdout, output.exit_code); + if !output.stderr.is_empty() { + result.stderr = output.stderr; + } + result + } +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "ssh")] + use super::*; + + #[test] + #[cfg(feature = "ssh")] + fn test_parse_user_host_with_user() { + let config = crate::ssh::SshConfig::new(); + let (user, host) = parse_user_host("deploy@db.supabase.co", &config); + assert_eq!(user, "deploy"); + assert_eq!(host, "db.supabase.co"); + } + + #[test] + #[cfg(feature = "ssh")] + fn test_parse_user_host_without_user() { + let config = crate::ssh::SshConfig::new().default_user("admin"); + let (user, host) = parse_user_host("db.supabase.co", &config); + assert_eq!(user, "admin"); + assert_eq!(host, "db.supabase.co"); + } + + #[test] + #[cfg(feature = "ssh")] + fn test_parse_user_host_no_default() { + let config = crate::ssh::SshConfig::new(); + let (user, host) = parse_user_host("db.supabase.co", &config); + assert_eq!(user, "root"); + assert_eq!(host, "db.supabase.co"); + } + + #[test] + #[cfg(feature = "ssh")] + fn test_parse_remote_path() { + assert_eq!( + parse_remote_path("user@host:/tmp/file"), + Some(("user@host".to_string(), "/tmp/file".to_string())) + ); + assert_eq!( + parse_remote_path("host:file.txt"), + Some(("host".to_string(), "file.txt".to_string())) + ); + assert_eq!(parse_remote_path("local_file.txt"), None); + } +} diff --git a/crates/bashkit/src/builtins/strings.rs b/crates/bashkit/src/builtins/strings.rs index 6be57d1e..31e1a15e 100644 --- a/crates/bashkit/src/builtins/strings.rs +++ b/crates/bashkit/src/builtins/strings.rs @@ -202,6 +202,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -231,6 +233,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/system.rs b/crates/bashkit/src/builtins/system.rs index 258eec7d..0f2b69cd 100644 --- a/crates/bashkit/src/builtins/system.rs +++ b/crates/bashkit/src/builtins/system.rs @@ -321,6 +321,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/template.rs b/crates/bashkit/src/builtins/template.rs index c9f2c5f2..f032e118 100644 --- a/crates/bashkit/src/builtins/template.rs +++ b/crates/bashkit/src/builtins/template.rs @@ -366,6 +366,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Template.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/timeout.rs b/crates/bashkit/src/builtins/timeout.rs index 37e69f63..f35827e4 100644 --- a/crates/bashkit/src/builtins/timeout.rs +++ b/crates/bashkit/src/builtins/timeout.rs @@ -202,6 +202,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -226,6 +228,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/tomlq.rs b/crates/bashkit/src/builtins/tomlq.rs index b14f2d71..80cee9b0 100644 --- a/crates/bashkit/src/builtins/tomlq.rs +++ b/crates/bashkit/src/builtins/tomlq.rs @@ -360,6 +360,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Tomlq.execute(ctx).await.unwrap() @@ -385,6 +387,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Tomlq.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/tree.rs b/crates/bashkit/src/builtins/tree.rs index 1f607f4f..7d709b54 100644 --- a/crates/bashkit/src/builtins/tree.rs +++ b/crates/bashkit/src/builtins/tree.rs @@ -250,6 +250,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Tree.execute(ctx).await.expect("tree execute failed") @@ -386,6 +388,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = Tree.execute(ctx).await.expect("tree failed"); diff --git a/crates/bashkit/src/builtins/verify.rs b/crates/bashkit/src/builtins/verify.rs index e0c59dac..32a61ba4 100644 --- a/crates/bashkit/src/builtins/verify.rs +++ b/crates/bashkit/src/builtins/verify.rs @@ -145,6 +145,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Verify.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/wc.rs b/crates/bashkit/src/builtins/wc.rs index 452bce9d..71fc45a8 100644 --- a/crates/bashkit/src/builtins/wc.rs +++ b/crates/bashkit/src/builtins/wc.rs @@ -267,6 +267,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; diff --git a/crates/bashkit/src/builtins/yaml.rs b/crates/bashkit/src/builtins/yaml.rs index 51693fe4..6726951a 100644 --- a/crates/bashkit/src/builtins/yaml.rs +++ b/crates/bashkit/src/builtins/yaml.rs @@ -537,6 +537,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Yaml.execute(ctx).await.unwrap() @@ -562,6 +564,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; Yaml.execute(ctx).await.unwrap() diff --git a/crates/bashkit/src/builtins/zip_cmd.rs b/crates/bashkit/src/builtins/zip_cmd.rs index 57981f51..c7ef9b37 100644 --- a/crates/bashkit/src/builtins/zip_cmd.rs +++ b/crates/bashkit/src/builtins/zip_cmd.rs @@ -400,6 +400,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -424,6 +426,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; @@ -465,6 +469,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = Zip.execute(ctx).await.unwrap(); @@ -490,6 +496,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = Zip.execute(ctx).await.unwrap(); @@ -661,6 +669,8 @@ mod tests { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, shell: None, }; let result = Unzip.execute(ctx).await.unwrap(); diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index c123a562..4503050e 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -202,7 +202,11 @@ fn unavailable_command_hint(name: &str) -> Option<&'static str> { Some("Package managers are not available in the sandbox.") } "sudo" | "su" | "doas" => Some("All commands run without privilege restrictions."), - "ssh" | "scp" | "sftp" | "rsync" => Some("Network access is limited to curl/wget."), + #[cfg(not(feature = "ssh"))] + "ssh" | "scp" | "sftp" => { + Some("SSH requires the 'ssh' feature. Enable with: features = [\"ssh\"]") + } + "rsync" => Some("Network access is limited to curl/wget."), "docker" | "podman" | "kubectl" | "systemctl" | "service" => { Some("Container and service management is not available in the sandbox.") } @@ -438,6 +442,9 @@ pub struct Interpreter { /// Git client for git builtins #[cfg(feature = "git")] git_client: Option, + /// SSH client for ssh/scp/sftp builtins + #[cfg(feature = "ssh")] + ssh_client: Option, /// Stdin inherited from pipeline for compound commands (while read, etc.) /// Each read operation consumes one line, advancing through the data. pipeline_stdin: Option, @@ -718,6 +725,14 @@ impl Interpreter { #[cfg(feature = "git")] builtins.insert("git".to_string(), Box::new(builtins::Git)); + // SSH builtins (requires ssh feature and configuration at runtime) + #[cfg(feature = "ssh")] + { + builtins.insert("ssh".to_string(), Box::new(builtins::Ssh)); + builtins.insert("scp".to_string(), Box::new(builtins::Scp)); + builtins.insert("sftp".to_string(), Box::new(builtins::Sftp)); + } + // Merge custom builtins (override defaults if same name) for (name, builtin) in custom_builtins { builtins.insert(name, builtin); @@ -769,6 +784,8 @@ impl Interpreter { http_client: None, #[cfg(feature = "git")] git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, pipeline_stdin: None, output_callback: None, output_emit_count: 0, @@ -1086,6 +1103,14 @@ impl Interpreter { self.git_client = Some(client); } + /// Set the SSH client for ssh/scp/sftp builtins. + /// + /// This is only available when the `ssh` feature is enabled. + #[cfg(feature = "ssh")] + pub fn set_ssh_client(&mut self, client: crate::ssh::SshClient) { + self.ssh_client = Some(client); + } + /// Execute a script. pub async fn execute(&mut self, script: &Script) -> Result { // Reset per-execution counters so each exec() gets a fresh budget. @@ -3708,6 +3733,8 @@ impl Interpreter { http_client: self.http_client.as_ref(), #[cfg(feature = "git")] git_client: self.git_client.as_ref(), + #[cfg(feature = "ssh")] + ssh_client: self.ssh_client.as_ref(), shell: Some(shell_ref), }; @@ -3753,6 +3780,8 @@ impl Interpreter { http_client: self.http_client.as_ref(), #[cfg(feature = "git")] git_client: self.git_client.as_ref(), + #[cfg(feature = "ssh")] + ssh_client: self.ssh_client.as_ref(), shell: Some(shell_ref), }; @@ -4953,6 +4982,8 @@ impl Interpreter { http_client: self.http_client.as_ref(), #[cfg(feature = "git")] git_client: self.git_client.as_ref(), + #[cfg(feature = "ssh")] + ssh_client: self.ssh_client.as_ref(), shell: Some(shell_ref), }; let mut result = builtin.execute(ctx).await?; diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 38fd8e36..72031f9b 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -413,6 +413,7 @@ pub mod parser; #[cfg(feature = "scripted_tool")] pub mod scripted_tool; mod snapshot; +mod ssh; /// Tool contract for LLM integration pub mod tool; /// Structured execution trace events. @@ -436,6 +437,7 @@ pub use limits::{ }; pub use network::NetworkAllowlist; pub use snapshot::Snapshot; +pub use ssh::{SshAllowlist, SshConfig}; pub use tool::BashToolBuilder as ToolBuilder; pub use tool::{ BashTool, BashToolBuilder, Tool, ToolError, ToolExecution, ToolImage, ToolOutput, @@ -463,6 +465,9 @@ pub use network::Response as HttpResponse; #[cfg(feature = "git")] pub use git::GitClient; +#[cfg(feature = "ssh")] +pub use ssh::{SshClient, SshHandler, SshOutput, SshTarget}; + #[cfg(feature = "python")] pub use builtins::{PythonExternalFnHandler, PythonExternalFns, PythonLimits}; // Re-export monty types needed by external handler consumers. @@ -1033,6 +1038,12 @@ pub struct BashBuilder { /// Git configuration for git builtins #[cfg(feature = "git")] git_config: Option, + /// SSH configuration for ssh/scp/sftp builtins + #[cfg(feature = "ssh")] + ssh_config: Option, + /// Custom SSH handler for transport interception + #[cfg(feature = "ssh")] + ssh_handler: Option>, /// Real host directories to mount in the VFS #[cfg(feature = "realfs")] real_mounts: Vec, @@ -1315,6 +1326,42 @@ impl BashBuilder { self } + /// Configure SSH access for ssh/scp/sftp builtins. + /// + /// # Example + /// + /// ```rust + /// use bashkit::{Bash, SshConfig}; + /// + /// let bash = Bash::builder() + /// .ssh(SshConfig::new() + /// .allow("*.supabase.co") + /// .default_user("root")) + /// .build(); + /// ``` + /// + /// # Threat Mitigations + /// + /// - TM-SSH-001: Unauthorized host access - host allowlist (default-deny) + /// - TM-SSH-002: Credential leakage - keys from VFS only + /// - TM-SSH-005: Connection hang - configurable timeouts + #[cfg(feature = "ssh")] + pub fn ssh(mut self, config: SshConfig) -> Self { + self.ssh_config = Some(config); + self + } + + /// Set a custom SSH handler for transport interception. + /// + /// Embedders can implement [`SshHandler`] to mock, proxy, log, or + /// rate-limit SSH operations. The allowlist check happens before + /// the handler is called. + #[cfg(feature = "ssh")] + pub fn ssh_handler(mut self, handler: Box) -> Self { + self.ssh_handler = Some(handler); + self + } + /// Enable embedded Python (`python`/`python3` builtins) via Monty interpreter /// with default resource limits. /// @@ -1854,6 +1901,10 @@ impl BashBuilder { self.log_config, #[cfg(feature = "git")] self.git_config, + #[cfg(feature = "ssh")] + self.ssh_config, + #[cfg(feature = "ssh")] + self.ssh_handler, ) } @@ -1938,6 +1989,8 @@ impl BashBuilder { #[cfg(feature = "http_client")] http_handler: Option>, #[cfg(feature = "logging")] log_config: Option, #[cfg(feature = "git")] git_config: Option, + #[cfg(feature = "ssh")] ssh_config: Option, + #[cfg(feature = "ssh")] ssh_handler: Option>, ) -> Bash { #[cfg(feature = "logging")] let log_config = log_config.unwrap_or_default(); @@ -1994,6 +2047,16 @@ impl BashBuilder { interpreter.set_git_client(client); } + // Configure SSH client for ssh/scp/sftp builtins + #[cfg(feature = "ssh")] + if let Some(config) = ssh_config { + let mut client = ssh::SshClient::new(config); + if let Some(handler) = ssh_handler { + client.set_handler(handler); + } + interpreter.set_ssh_client(client); + } + // Configure persistent history file if let Some(hf) = history_file { interpreter.set_history_file(hf); @@ -2107,6 +2170,13 @@ pub mod python_guide {} #[doc = include_str!("../docs/typescript.md")] pub mod typescript_guide {} +/// Guide for SSH/SCP/SFTP remote operations. +/// +/// **Related:** [`BashBuilder::ssh`], [`SshConfig`], [`SshAllowlist`], [`threat_model`] +#[cfg(feature = "ssh")] +#[doc = include_str!("../docs/ssh.md")] +pub mod ssh_guide {} + /// Guide for live mount/unmount on a running Bash instance. /// /// This guide covers: diff --git a/crates/bashkit/src/ssh/allowlist.rs b/crates/bashkit/src/ssh/allowlist.rs new file mode 100644 index 00000000..aa529b09 --- /dev/null +++ b/crates/bashkit/src/ssh/allowlist.rs @@ -0,0 +1,300 @@ +//! Host allowlist for SSH access control. +//! +//! Provides a whitelist-based security model for SSH connections. +//! +//! # Security Mitigations +//! +//! - **TM-SSH-001**: Unauthorized host access → host allowlist (default-deny) +//! - **TM-SSH-007**: Port scanning → port allowlist + +use std::collections::HashSet; + +/// SSH host allowlist configuration. +/// +/// Hosts must match an entry in the allowlist to be connected to. +/// An empty allowlist means all hosts are blocked (secure by default). +/// +/// # Examples +/// +/// ```rust +/// use bashkit::SshAllowlist; +/// +/// let allowlist = SshAllowlist::new() +/// .allow("db.abc123.supabase.co") +/// .allow("*.example.com"); +/// +/// assert!(allowlist.is_allowed("db.abc123.supabase.co", 22)); +/// assert!(allowlist.is_allowed("staging.example.com", 22)); +/// assert!(!allowlist.is_allowed("evil.com", 22)); +/// ``` +/// +/// # Pattern Matching +/// +/// - **Exact host**: `db.abc123.supabase.co` +/// - **Wildcard subdomain**: `*.supabase.co` matches `db.abc.supabase.co` +/// - **IP address**: `192.168.1.100` +/// - **Port check**: Host must be allowed AND port must be in allowed set +#[derive(Debug, Clone, Default)] +pub struct SshAllowlist { + /// Host patterns that are allowed. + /// Supports exact match and `*.domain.com` wildcard patterns. + patterns: HashSet, + + /// Allowed ports. Empty means default port 22 only. + allowed_ports: HashSet, + + /// If true, allow all hosts (dangerous - testing only). + allow_all: bool, +} + +/// Result of matching a host against the allowlist. +#[derive(Debug, Clone, PartialEq)] +pub enum SshMatch { + /// Host and port are allowed. + Allowed, + /// Host or port is blocked. + Blocked { reason: String }, +} + +impl SshAllowlist { + /// Create a new empty allowlist (blocks all hosts). + pub fn new() -> Self { + Self::default() + } + + /// Create an allowlist that allows all hosts. + /// + /// # Warning + /// + /// This is dangerous and should only be used for testing. + pub fn allow_all() -> Self { + Self { + patterns: HashSet::new(), + allowed_ports: HashSet::new(), + allow_all: true, + } + } + + /// Add a host pattern to the allowlist. + /// + /// Patterns can be: + /// - Exact host: `db.abc123.supabase.co` + /// - Wildcard subdomain: `*.supabase.co` + /// - IP address: `192.168.1.100` + pub fn allow(mut self, pattern: impl Into) -> Self { + self.patterns.insert(pattern.into()); + self + } + + /// Add multiple host patterns. + pub fn allow_many(mut self, patterns: impl IntoIterator>) -> Self { + for p in patterns { + self.patterns.insert(p.into()); + } + self + } + + /// Add an allowed port. If no ports are added, only port 22 is allowed. + pub fn allow_port(mut self, port: u16) -> Self { + self.allowed_ports.insert(port); + self + } + + /// Check if a host + port combination is allowed. + pub fn check(&self, host: &str, port: u16) -> SshMatch { + if self.allow_all { + return SshMatch::Allowed; + } + + // Check port first + if !self.is_port_allowed(port) { + return SshMatch::Blocked { + reason: format!("SSH port {} is not allowed", port), + }; + } + + // Empty allowlist blocks everything + if self.patterns.is_empty() { + return SshMatch::Blocked { + reason: "no SSH hosts are allowed (empty allowlist)".to_string(), + }; + } + + // Check host against patterns + for pattern in &self.patterns { + if Self::matches_pattern(host, pattern) { + return SshMatch::Allowed; + } + } + + SshMatch::Blocked { + reason: format!("SSH host '{}' is not in allowlist", host), + } + } + + /// Convenience method: is this host+port allowed? + pub fn is_allowed(&self, host: &str, port: u16) -> bool { + matches!(self.check(host, port), SshMatch::Allowed) + } + + /// Check if network access is enabled. + pub fn is_enabled(&self) -> bool { + self.allow_all || !self.patterns.is_empty() + } + + fn is_port_allowed(&self, port: u16) -> bool { + if self.allow_all { + return true; + } + if self.allowed_ports.is_empty() { + // Default: only port 22 + return port == 22; + } + self.allowed_ports.contains(&port) + } + + /// Match a hostname against a pattern. + /// + /// - Exact match: `host == pattern` + /// - Wildcard: `*.domain.com` matches `any.domain.com` and `deep.any.domain.com` + fn matches_pattern(host: &str, pattern: &str) -> bool { + if host == pattern { + return true; + } + + // Wildcard pattern: *.domain.com + if let Some(suffix) = pattern.strip_prefix("*.") { + // Host must end with .suffix and have at least one char before the dot + if let Some(prefix) = host.strip_suffix(suffix) { + // prefix should end with '.' (e.g., "db." from "db.supabase.co") + return prefix.ends_with('.'); + } + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_allowlist_blocks_all() { + let allowlist = SshAllowlist::new(); + assert!(matches!( + allowlist.check("example.com", 22), + SshMatch::Blocked { .. } + )); + } + + #[test] + fn test_allow_all() { + let allowlist = SshAllowlist::allow_all(); + assert_eq!(allowlist.check("anything.com", 22), SshMatch::Allowed); + assert_eq!(allowlist.check("anything.com", 2222), SshMatch::Allowed); + } + + #[test] + fn test_exact_host_match() { + let allowlist = SshAllowlist::new().allow("db.supabase.co"); + + assert_eq!(allowlist.check("db.supabase.co", 22), SshMatch::Allowed); + assert!(matches!( + allowlist.check("other.supabase.co", 22), + SshMatch::Blocked { .. } + )); + assert!(matches!( + allowlist.check("evil.com", 22), + SshMatch::Blocked { .. } + )); + } + + #[test] + fn test_wildcard_pattern() { + let allowlist = SshAllowlist::new().allow("*.supabase.co"); + + assert_eq!(allowlist.check("db.supabase.co", 22), SshMatch::Allowed); + assert_eq!( + allowlist.check("staging.supabase.co", 22), + SshMatch::Allowed + ); + assert_eq!( + allowlist.check("deep.nested.supabase.co", 22), + SshMatch::Allowed + ); + + // Must have subdomain + assert!(matches!( + allowlist.check("supabase.co", 22), + SshMatch::Blocked { .. } + )); + assert!(matches!( + allowlist.check("evil.com", 22), + SshMatch::Blocked { .. } + )); + } + + #[test] + fn test_port_restriction_default() { + let allowlist = SshAllowlist::new().allow("example.com"); + + // Default: only port 22 + assert_eq!(allowlist.check("example.com", 22), SshMatch::Allowed); + assert!(matches!( + allowlist.check("example.com", 2222), + SshMatch::Blocked { .. } + )); + } + + #[test] + fn test_port_restriction_custom() { + let allowlist = SshAllowlist::new() + .allow("example.com") + .allow_port(22) + .allow_port(2222); + + assert_eq!(allowlist.check("example.com", 22), SshMatch::Allowed); + assert_eq!(allowlist.check("example.com", 2222), SshMatch::Allowed); + assert!(matches!( + allowlist.check("example.com", 3333), + SshMatch::Blocked { .. } + )); + } + + #[test] + fn test_ip_address() { + let allowlist = SshAllowlist::new().allow("192.168.1.100"); + assert_eq!(allowlist.check("192.168.1.100", 22), SshMatch::Allowed); + assert!(matches!( + allowlist.check("192.168.1.101", 22), + SshMatch::Blocked { .. } + )); + } + + #[test] + fn test_multiple_patterns() { + let allowlist = SshAllowlist::new() + .allow("*.supabase.co") + .allow("bastion.example.com") + .allow("10.0.0.1"); + + assert_eq!(allowlist.check("db.supabase.co", 22), SshMatch::Allowed); + assert_eq!( + allowlist.check("bastion.example.com", 22), + SshMatch::Allowed + ); + assert_eq!(allowlist.check("10.0.0.1", 22), SshMatch::Allowed); + assert!(matches!( + allowlist.check("evil.com", 22), + SshMatch::Blocked { .. } + )); + } + + #[test] + fn test_is_enabled() { + assert!(!SshAllowlist::new().is_enabled()); + assert!(SshAllowlist::new().allow("x.com").is_enabled()); + assert!(SshAllowlist::allow_all().is_enabled()); + } +} diff --git a/crates/bashkit/src/ssh/client.rs b/crates/bashkit/src/ssh/client.rs new file mode 100644 index 00000000..8589c4eb --- /dev/null +++ b/crates/bashkit/src/ssh/client.rs @@ -0,0 +1,374 @@ +//! SSH client with allowlist-based access control. +//! +//! Wraps an [`SshHandler`] with host allowlist enforcement. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::allowlist::SshMatch; +use super::config::SshConfig; +use super::handler::{SshHandler, SshOutput, SshTarget}; +use super::russh_handler::RusshHandler; + +/// SSH client with allowlist-based access control. +/// +/// Enforces the host allowlist before delegating to the handler. +/// Tracks active session count for resource limiting. +pub struct SshClient { + config: SshConfig, + handler: Option>, + default_handler: RusshHandler, + active_sessions: AtomicUsize, +} + +impl std::fmt::Debug for SshClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SshClient") + .field("config", &self.config) + .field("has_custom_handler", &self.handler.is_some()) + .field( + "active_sessions", + &self.active_sessions.load(Ordering::Relaxed), + ) + .finish() + } +} + +impl SshClient { + /// Create a new SSH client with the given configuration. + /// + /// Uses the default `russh`-based transport. Override with + /// [`set_handler`](Self::set_handler) for custom transports. + pub fn new(config: SshConfig) -> Self { + let default_handler = RusshHandler::new(config.timeout); + Self { + config, + handler: None, + default_handler, + active_sessions: AtomicUsize::new(0), + } + } + + /// Set a custom SSH handler. + pub fn set_handler(&mut self, handler: Box) { + self.handler = Some(handler); + } + + /// Get the SSH configuration. + pub fn config(&self) -> &SshConfig { + &self.config + } + + /// Open a shell session (no command) and capture output. + /// + /// Used for SSH services like `ssh supabase.sh` that present a TUI + /// or greeting on connect without requiring a command. + pub async fn shell(&self, target: &SshTarget) -> std::result::Result { + self.check_allowed(&target.host, target.port)?; + self.acquire_session()?; + let result = self.handler().shell(target).await; + self.release_session(); + + if let Ok(ref output) = result { + let total = output.stdout.len() + output.stderr.len(); + if total > self.config.max_response_bytes { + return Err(format!( + "ssh: response too large ({} bytes, max {})", + total, self.config.max_response_bytes + )); + } + } + + result + } + + /// Execute a command on a remote host. + /// + /// # Security (TM-SSH-001) + /// + /// The host is validated against the allowlist before connecting. + pub async fn exec( + &self, + target: &SshTarget, + command: &str, + ) -> std::result::Result { + // THREAT[TM-SSH-001]: Validate host against allowlist + self.check_allowed(&target.host, target.port)?; + + // THREAT[TM-SSH-003]: Check session limit + self.acquire_session()?; + let result = self.exec_inner(target, command).await; + self.release_session(); + + // THREAT[TM-SSH-004]: Enforce response size limit + if let Ok(ref output) = result { + let total = output.stdout.len() + output.stderr.len(); + if total > self.config.max_response_bytes { + return Err(format!( + "ssh: response too large ({} bytes, max {})", + total, self.config.max_response_bytes + )); + } + } + + result + } + + /// Upload a file to a remote host. + pub async fn upload( + &self, + target: &SshTarget, + remote_path: &str, + content: &[u8], + mode: u32, + ) -> std::result::Result<(), String> { + self.check_allowed(&target.host, target.port)?; + self.acquire_session()?; + let result = self.upload_inner(target, remote_path, content, mode).await; + self.release_session(); + result + } + + /// Download a file from a remote host. + pub async fn download( + &self, + target: &SshTarget, + remote_path: &str, + ) -> std::result::Result, String> { + self.check_allowed(&target.host, target.port)?; + self.acquire_session()?; + let result = self.download_inner(target, remote_path).await; + self.release_session(); + + // THREAT[TM-SSH-004]: Enforce response size limit + if let Ok(ref data) = result + && data.len() > self.config.max_response_bytes + { + return Err(format!( + "ssh: download too large ({} bytes, max {})", + data.len(), + self.config.max_response_bytes + )); + } + + result + } + + fn check_allowed(&self, host: &str, port: u16) -> std::result::Result<(), String> { + match self.config.allowlist.check(host, port) { + SshMatch::Allowed => Ok(()), + SshMatch::Blocked { reason } => Err(format!("ssh: {}", reason)), + } + } + + fn acquire_session(&self) -> std::result::Result<(), String> { + let current = self.active_sessions.fetch_add(1, Ordering::SeqCst); + if current >= self.config.max_sessions { + self.active_sessions.fetch_sub(1, Ordering::SeqCst); + return Err(format!( + "ssh: too many active sessions ({}, max {})", + current, self.config.max_sessions + )); + } + Ok(()) + } + + fn release_session(&self) { + self.active_sessions.fetch_sub(1, Ordering::SeqCst); + } + + /// Get the handler: custom if set, otherwise default RusshHandler. + fn handler(&self) -> &dyn SshHandler { + match self.handler { + Some(ref h) => h.as_ref(), + None => &self.default_handler, + } + } + + async fn exec_inner( + &self, + target: &SshTarget, + command: &str, + ) -> std::result::Result { + self.handler().exec(target, command).await + } + + async fn upload_inner( + &self, + target: &SshTarget, + remote_path: &str, + content: &[u8], + mode: u32, + ) -> std::result::Result<(), String> { + self.handler() + .upload(target, remote_path, content, mode) + .await + } + + async fn download_inner( + &self, + target: &SshTarget, + remote_path: &str, + ) -> std::result::Result, String> { + self.handler().download(target, remote_path).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> SshConfig { + SshConfig::new().allow("*.supabase.co").allow("10.0.0.1") + } + + fn test_target(host: &str) -> SshTarget { + SshTarget { + host: host.to_string(), + port: 22, + user: "root".to_string(), + private_key: None, + password: None, + } + } + + #[tokio::test] + async fn test_blocked_host() { + let client = SshClient::new(test_config()); + let target = test_target("evil.com"); + let result = client.exec(&target, "ls").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not in allowlist")); + } + + #[tokio::test] + async fn test_blocked_port() { + let client = SshClient::new(test_config()); + let mut target = test_target("db.supabase.co"); + target.port = 3333; + let result = client.exec(&target, "ls").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("port")); + } + + #[tokio::test] + async fn test_allowed_host_default_handler_connect_fails() { + let client = SshClient::new(test_config()); + let target = test_target("db.supabase.co"); + let result = client.exec(&target, "ls").await; + // Allowed host, but connection fails (no real server) + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("connection failed") || err.contains("no authentication"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_session_limit() { + let config = SshConfig::new().allow_all().max_sessions(1); + let client = SshClient::new(config); + + // Simulate one active session + client.active_sessions.store(1, Ordering::SeqCst); + + let target = test_target("any.host"); + let result = client.exec(&target, "ls").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("too many active sessions")); + } + + #[tokio::test] + async fn test_with_mock_handler() { + struct MockHandler; + + #[async_trait::async_trait] + impl SshHandler for MockHandler { + async fn exec( + &self, + target: &SshTarget, + command: &str, + ) -> std::result::Result { + Ok(SshOutput { + stdout: format!("{}@{}: {}\n", target.user, target.host, command), + stderr: String::new(), + exit_code: 0, + }) + } + + async fn upload( + &self, + _target: &SshTarget, + _path: &str, + _content: &[u8], + _mode: u32, + ) -> std::result::Result<(), String> { + Ok(()) + } + + async fn download( + &self, + _target: &SshTarget, + _path: &str, + ) -> std::result::Result, String> { + Ok(b"file content".to_vec()) + } + } + + let mut client = SshClient::new(SshConfig::new().allow("*.supabase.co")); + client.set_handler(Box::new(MockHandler)); + + let target = test_target("db.supabase.co"); + let result = client.exec(&target, "psql -c 'SELECT 1'").await; + assert!(result.is_ok()); + let output = result.unwrap(); + assert_eq!(output.stdout, "root@db.supabase.co: psql -c 'SELECT 1'\n"); + assert_eq!(output.exit_code, 0); + } + + #[tokio::test] + async fn test_response_size_limit() { + struct LargeOutputHandler; + + #[async_trait::async_trait] + impl SshHandler for LargeOutputHandler { + async fn exec( + &self, + _target: &SshTarget, + _command: &str, + ) -> std::result::Result { + Ok(SshOutput { + stdout: "x".repeat(20_000_000), // 20MB + stderr: String::new(), + exit_code: 0, + }) + } + + async fn upload( + &self, + _: &SshTarget, + _: &str, + _: &[u8], + _: u32, + ) -> std::result::Result<(), String> { + Ok(()) + } + + async fn download( + &self, + _: &SshTarget, + _: &str, + ) -> std::result::Result, String> { + Ok(Vec::new()) + } + } + + let mut client = SshClient::new(SshConfig::new().allow_all()); + client.set_handler(Box::new(LargeOutputHandler)); + + let target = test_target("host.com"); + let result = client.exec(&target, "cat bigfile").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("response too large")); + } +} diff --git a/crates/bashkit/src/ssh/config.rs b/crates/bashkit/src/ssh/config.rs new file mode 100644 index 00000000..17193d02 --- /dev/null +++ b/crates/bashkit/src/ssh/config.rs @@ -0,0 +1,223 @@ +//! SSH configuration for Bashkit. +//! +//! # Security Mitigations +//! +//! - **TM-SSH-001**: Unauthorized host access → host allowlist (default-deny) +//! - **TM-SSH-002**: Credential leakage → keys from VFS only +//! - **TM-SSH-003**: Session exhaustion → max concurrent sessions +//! - **TM-SSH-005**: Connection hang → configurable timeouts + +use std::time::Duration; + +use super::allowlist::SshAllowlist; + +/// Default SSH connection timeout. +pub const DEFAULT_TIMEOUT_SECS: u64 = 30; + +/// Default maximum response size (10 MB). +pub const DEFAULT_MAX_RESPONSE_BYTES: usize = 10_000_000; + +/// Default maximum concurrent sessions. +pub const DEFAULT_MAX_SESSIONS: usize = 5; + +/// Default SSH port. +pub const DEFAULT_PORT: u16 = 22; + +/// SSH configuration for Bashkit. +/// +/// Controls SSH behavior including host allowlist, authentication, +/// timeouts, and resource limits. +/// +/// # Example +/// +/// ```rust +/// use bashkit::SshConfig; +/// use std::time::Duration; +/// +/// let config = SshConfig::new() +/// .allow("*.supabase.co") +/// .allow("bastion.example.com") +/// .allow_port(2222) +/// .default_user("deploy") +/// .timeout(Duration::from_secs(60)); +/// ``` +/// +/// # Security +/// +/// - Host allowlist is default-deny (empty blocks everything) +/// - Keys are read from VFS only, never from host filesystem +/// - All connections have timeouts to prevent hangs +#[derive(Debug, Clone)] +pub struct SshConfig { + /// Host allowlist + pub(crate) allowlist: SshAllowlist, + /// Default username for connections + pub(crate) default_user: Option, + /// Default password for connections + pub(crate) default_password: Option, + /// Default private key (PEM/OpenSSH format) for connections + pub(crate) default_private_key: Option, + /// Connection timeout + pub(crate) timeout: Duration, + /// Maximum response body size in bytes + pub(crate) max_response_bytes: usize, + /// Maximum concurrent SSH sessions + pub(crate) max_sessions: usize, + /// Default port + pub(crate) default_port: u16, +} + +impl Default for SshConfig { + fn default() -> Self { + Self { + allowlist: SshAllowlist::new(), + default_user: None, + default_password: None, + default_private_key: None, + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS), + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + max_sessions: DEFAULT_MAX_SESSIONS, + default_port: DEFAULT_PORT, + } + } +} + +impl SshConfig { + /// Create a new SSH configuration with default settings. + pub fn new() -> Self { + Self::default() + } + + /// Add a host pattern to the allowlist. + /// + /// Patterns can be exact hosts (`db.supabase.co`) or + /// wildcard subdomains (`*.supabase.co`). + /// + /// # Security (TM-SSH-001) + /// + /// Only hosts matching the allowlist can be connected to. + pub fn allow(mut self, pattern: impl Into) -> Self { + self.allowlist = self.allowlist.allow(pattern); + self + } + + /// Add multiple host patterns. + pub fn allow_many(mut self, patterns: impl IntoIterator>) -> Self { + self.allowlist = self.allowlist.allow_many(patterns); + self + } + + /// Add an allowed port. Default: only port 22. + /// + /// # Security (TM-SSH-007) + pub fn allow_port(mut self, port: u16) -> Self { + self.allowlist = self.allowlist.allow_port(port); + self + } + + /// Allow all hosts (dangerous — testing only). + pub fn allow_all(mut self) -> Self { + self.allowlist = SshAllowlist::allow_all(); + self + } + + /// Set the default username for SSH connections. + /// + /// Used when no `user@` prefix is specified in the ssh command. + pub fn default_user(mut self, user: impl Into) -> Self { + self.default_user = Some(user.into()); + self + } + + /// Set the default password for SSH connections. + /// + /// Used when no private key is provided. Typically set from + /// environment variables or secret stores, not hardcoded. + pub fn default_password(mut self, password: impl Into) -> Self { + self.default_password = Some(password.into()); + self + } + + /// Set the default private key (PEM or OpenSSH format). + /// + /// Used when no `-i` flag is specified in the ssh command. + /// Pass the key contents, not a file path. + pub fn default_private_key(mut self, key: impl Into) -> Self { + self.default_private_key = Some(key.into()); + self + } + + /// Set the connection timeout. + /// + /// # Security (TM-SSH-005) + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Set the maximum response size in bytes. + /// + /// # Security (TM-SSH-004) + pub fn max_response_bytes(mut self, max: usize) -> Self { + self.max_response_bytes = max; + self + } + + /// Set the maximum concurrent SSH sessions. + /// + /// # Security (TM-SSH-003) + pub fn max_sessions(mut self, max: usize) -> Self { + self.max_sessions = max; + self + } + + /// Set the default SSH port. + pub fn default_port(mut self, port: u16) -> Self { + self.default_port = port; + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = SshConfig::new(); + assert!(!config.allowlist.is_enabled()); + assert!(config.default_user.is_none()); + assert_eq!(config.timeout, Duration::from_secs(30)); + assert_eq!(config.max_response_bytes, 10_000_000); + assert_eq!(config.max_sessions, 5); + assert_eq!(config.default_port, 22); + } + + #[test] + fn test_builder_chain() { + let config = SshConfig::new() + .allow("*.supabase.co") + .allow("bastion.example.com") + .allow_port(2222) + .default_user("deploy") + .timeout(Duration::from_secs(60)) + .max_response_bytes(5_000_000) + .max_sessions(3) + .default_port(2222); + + assert!(config.allowlist.is_enabled()); + assert_eq!(config.default_user.as_deref(), Some("deploy")); + assert_eq!(config.timeout, Duration::from_secs(60)); + assert_eq!(config.max_response_bytes, 5_000_000); + assert_eq!(config.max_sessions, 3); + assert_eq!(config.default_port, 2222); + } + + #[test] + fn test_allowlist_integration() { + let config = SshConfig::new().allow("*.supabase.co").allow_port(22); + + assert!(config.allowlist.is_allowed("db.supabase.co", 22)); + assert!(!config.allowlist.is_allowed("evil.com", 22)); + } +} diff --git a/crates/bashkit/src/ssh/handler.rs b/crates/bashkit/src/ssh/handler.rs new file mode 100644 index 00000000..ee9cab49 --- /dev/null +++ b/crates/bashkit/src/ssh/handler.rs @@ -0,0 +1,128 @@ +//! SSH handler trait for pluggable transport implementations. +//! +//! Embedders can implement [`SshHandler`] to intercept, proxy, log, +//! or mock SSH operations. The allowlist check happens _before_ the +//! handler is called, so the security boundary stays in bashkit. +//! +//! # Default +//! +//! When no custom handler is set, `SshClient` uses `russh` directly. + +use async_trait::async_trait; + +/// Connection target for an SSH operation. +/// +/// Fully resolved by the builtin before passing to the handler. +/// The handler does NOT need to validate the host — that's already done. +#[derive(Debug, Clone)] +pub struct SshTarget { + /// Remote hostname or IP. + pub host: String, + /// Remote port. + pub port: u16, + /// Username for authentication. + pub user: String, + /// Optional private key (PEM contents from VFS, not a file path). + pub private_key: Option, + /// Optional password. + pub password: Option, +} + +/// Output from a remote command execution. +#[derive(Debug, Clone, Default)] +pub struct SshOutput { + /// Standard output. + pub stdout: String, + /// Standard error. + pub stderr: String, + /// Remote exit code. + pub exit_code: i32, +} + +/// Trait for custom SSH transport implementations. +/// +/// Embedders can implement this to: +/// - Mock SSH for testing +/// - Proxy through a bastion host +/// - Log/audit all SSH operations +/// - Rate-limit connections +/// +/// The allowlist check happens _before_ the handler is called. +/// +/// # Example +/// +/// ```rust,ignore +/// use bashkit::ssh::{SshHandler, SshTarget, SshOutput}; +/// use async_trait::async_trait; +/// +/// struct MockSsh; +/// +/// #[async_trait] +/// impl SshHandler for MockSsh { +/// async fn exec( +/// &self, +/// target: &SshTarget, +/// command: &str, +/// ) -> Result { +/// Ok(SshOutput { +/// stdout: format!("mock: ran '{}' on {}\n", command, target.host), +/// stderr: String::new(), +/// exit_code: 0, +/// }) +/// } +/// +/// async fn upload( +/// &self, _target: &SshTarget, _remote_path: &str, +/// _content: &[u8], _mode: u32, +/// ) -> Result<(), String> { +/// Ok(()) +/// } +/// +/// async fn download( +/// &self, _target: &SshTarget, _remote_path: &str, +/// ) -> Result, String> { +/// Ok(Vec::new()) +/// } +/// } +/// ``` +#[async_trait] +pub trait SshHandler: Send + Sync { + /// Execute a command on a remote host and return its output. + /// + /// Called after the host has been validated against the allowlist. + async fn exec( + &self, + target: &SshTarget, + command: &str, + ) -> std::result::Result; + + /// Open a shell session (no command) and capture output. + /// + /// Used for SSH services that present a TUI or greeting on connect + /// (e.g. `ssh supabase.sh`). The session closes when the remote + /// side sends EOF or the timeout expires. + async fn shell(&self, target: &SshTarget) -> std::result::Result { + // Default: delegate to exec with empty shell invocation + self.exec(target, "").await + } + + /// Upload file content to a remote path (scp put / sftp put). + /// + /// Called after the host has been validated against the allowlist. + async fn upload( + &self, + target: &SshTarget, + remote_path: &str, + content: &[u8], + mode: u32, + ) -> std::result::Result<(), String>; + + /// Download a file from a remote path (scp get / sftp get). + /// + /// Called after the host has been validated against the allowlist. + async fn download( + &self, + target: &SshTarget, + remote_path: &str, + ) -> std::result::Result, String>; +} diff --git a/crates/bashkit/src/ssh/mod.rs b/crates/bashkit/src/ssh/mod.rs new file mode 100644 index 00000000..1fb432b3 --- /dev/null +++ b/crates/bashkit/src/ssh/mod.rs @@ -0,0 +1,50 @@ +//! SSH support for Bashkit +//! +//! Provides SSH/SCP/SFTP operations via the `ssh` feature flag. +//! Follows the same opt-in pattern as `git` and `http_client`. +//! +//! # Security Model +//! +//! - **Disabled by default**: SSH requires explicit configuration +//! - **Host allowlist**: Only allowed hosts can be connected to (default-deny) +//! - **No credential leakage**: Keys read from VFS only, never host `~/.ssh/` +//! - **Resource limits**: Timeouts, max response size, max sessions +//! +//! # Usage +//! +//! ```rust,ignore +//! use bashkit::{Bash, SshConfig}; +//! +//! let mut bash = Bash::builder() +//! .ssh(SshConfig::new() +//! .allow("*.supabase.co") +//! .default_user("root")) +//! .build(); +//! +//! let result = bash.exec("ssh db.abc.supabase.co 'psql -c \"SELECT 1\"'").await?; +//! ``` +//! +//! # Security Threats +//! +//! See `specs/015-ssh-support.md` and `specs/006-threat-model.md` (TM-SSH-*) + +mod allowlist; +mod config; + +#[cfg(feature = "ssh")] +mod client; + +#[cfg(feature = "ssh")] +mod handler; + +#[cfg(feature = "ssh")] +mod russh_handler; + +pub use allowlist::SshAllowlist; +pub use config::SshConfig; + +#[cfg(feature = "ssh")] +pub use client::SshClient; + +#[cfg(feature = "ssh")] +pub use handler::{SshHandler, SshOutput, SshTarget}; diff --git a/crates/bashkit/src/ssh/russh_handler.rs b/crates/bashkit/src/ssh/russh_handler.rs new file mode 100644 index 00000000..384d4e71 --- /dev/null +++ b/crates/bashkit/src/ssh/russh_handler.rs @@ -0,0 +1,266 @@ +//! Default SSH handler using russh. +//! +//! Provides a real SSH transport backed by the `russh` crate. +//! Used automatically when no custom [`SshHandler`] is set. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use base64::Engine; + +use super::handler::{SshHandler, SshOutput, SshTarget}; + +/// Shell-escape a string for safe interpolation into a remote command. +/// Wraps in single quotes and escapes embedded single quotes. +fn shell_escape(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\\''")) +} + +/// SSH client handler that accepts all server keys. +/// +/// THREAT[TM-SSH-006]: In production, embedders should implement +/// `SshHandler` with proper host key verification. This default +/// handler accepts all keys for simplicity. +struct ClientHandler; + +impl russh::client::Handler for ClientHandler { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + _server_public_key: &russh::keys::PublicKey, + ) -> Result { + // Accept all host keys. Embedders needing strict verification + // should implement their own SshHandler. + Ok(true) + } +} + +/// Default SSH transport using russh. +/// +/// Supports password and private key authentication. +/// SCP/SFTP are implemented via remote commands (`cat`, `base64`). +pub struct RusshHandler { + timeout: Duration, +} + +impl RusshHandler { + pub fn new(timeout: Duration) -> Self { + Self { timeout } + } + + /// Connect and authenticate to a remote host. + async fn connect( + &self, + target: &SshTarget, + ) -> std::result::Result, String> { + let config = russh::client::Config { + inactivity_timeout: Some(self.timeout), + ..<_>::default() + }; + + let addr = (target.host.as_str(), target.port); + let mut session = russh::client::connect(Arc::new(config), addr, ClientHandler) + .await + .map_err(|e| format!("connection failed: {e}"))?; + + // Authenticate: try "none" first (public SSH services like supabase.sh), + // then private key, then password. + if let Some(ref key_pem) = target.private_key { + let key_pair = russh::keys::PrivateKey::from_openssh(key_pem.as_bytes()) + .map_err(|e| format!("invalid private key: {e}"))?; + let auth = session + .authenticate_publickey( + &target.user, + russh::keys::PrivateKeyWithHashAlg::new( + Arc::new(key_pair), + session + .best_supported_rsa_hash() + .await + .ok() + .flatten() + .flatten(), + ), + ) + .await + .map_err(|e| format!("publickey auth failed: {e}"))?; + if !auth.success() { + return Err("publickey authentication rejected".to_string()); + } + } else if let Some(ref password) = target.password { + let auth = session + .authenticate_password(&target.user, password) + .await + .map_err(|e| format!("password auth failed: {e}"))?; + if !auth.success() { + return Err("password authentication rejected".to_string()); + } + } else { + // No credentials — try "none" auth (works for public SSH services) + let auth = session + .authenticate_none(&target.user) + .await + .map_err(|e| format!("auth failed: {e}"))?; + if !auth.success() { + return Err("ssh: authentication failed (server requires credentials)".to_string()); + } + } + + Ok(session) + } +} + +#[async_trait] +impl SshHandler for RusshHandler { + async fn exec( + &self, + target: &SshTarget, + command: &str, + ) -> std::result::Result { + let session = self.connect(target).await?; + + let mut channel = session + .channel_open_session() + .await + .map_err(|e| format!("channel open failed: {e}"))?; + + channel + .exec(true, command) + .await + .map_err(|e| format!("exec failed: {e}"))?; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code: Option = None; + + loop { + let Some(msg) = channel.wait().await else { + break; + }; + match msg { + russh::ChannelMsg::Data { ref data } => { + stdout.extend_from_slice(data); + } + russh::ChannelMsg::ExtendedData { ref data, ext } => { + if ext == 1 { + // stderr + stderr.extend_from_slice(data); + } + } + russh::ChannelMsg::ExitStatus { exit_status } => { + exit_code = Some(exit_status); + } + _ => {} + } + } + + let _ = session + .disconnect(russh::Disconnect::ByApplication, "", "") + .await; + + Ok(SshOutput { + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + exit_code: exit_code.unwrap_or(0) as i32, + }) + } + + async fn shell(&self, target: &SshTarget) -> std::result::Result { + let session = self.connect(target).await?; + + let mut channel = session + .channel_open_session() + .await + .map_err(|e| format!("channel open failed: {e}"))?; + + // Request a PTY so the remote TUI sends output + channel + .request_pty(false, "xterm", 80, 24, 0, 0, &[]) + .await + .map_err(|e| format!("pty request failed: {e}"))?; + + channel + .request_shell(true) + .await + .map_err(|e| format!("shell request failed: {e}"))?; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut exit_code: Option = None; + + loop { + let Some(msg) = channel.wait().await else { + break; + }; + match msg { + russh::ChannelMsg::Data { ref data } => { + stdout.extend_from_slice(data); + } + russh::ChannelMsg::ExtendedData { ref data, ext } => { + if ext == 1 { + stderr.extend_from_slice(data); + } + } + russh::ChannelMsg::ExitStatus { exit_status } => { + exit_code = Some(exit_status); + } + _ => {} + } + } + + let _ = session + .disconnect(russh::Disconnect::ByApplication, "", "") + .await; + + Ok(SshOutput { + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + exit_code: exit_code.unwrap_or(0) as i32, + }) + } + + async fn upload( + &self, + target: &SshTarget, + remote_path: &str, + content: &[u8], + mode: u32, + ) -> std::result::Result<(), String> { + // THREAT[TM-SSH-008]: Shell-escape remote path to prevent injection + let b64 = base64::engine::general_purpose::STANDARD.encode(content); + let escaped_path = shell_escape(remote_path); + let cmd = format!( + "echo '{}' | base64 -d > {} && chmod {:o} {}", + b64, escaped_path, mode, escaped_path + ); + let result = self.exec(target, &cmd).await?; + if result.exit_code != 0 { + return Err(format!( + "upload failed (exit {}): {}", + result.exit_code, result.stderr + )); + } + Ok(()) + } + + async fn download( + &self, + target: &SshTarget, + remote_path: &str, + ) -> std::result::Result, String> { + // THREAT[TM-SSH-008]: Shell-escape remote path to prevent injection + let cmd = format!("base64 < {}", shell_escape(remote_path)); + let result = self.exec(target, &cmd).await?; + if result.exit_code != 0 { + return Err(format!( + "download failed (exit {}): {}", + result.exit_code, result.stderr + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(result.stdout.trim()) + .map_err(|e| format!("base64 decode failed: {e}"))?; + Ok(decoded) + } +} diff --git a/crates/bashkit/tests/script_execution_tests.rs b/crates/bashkit/tests/script_execution_tests.rs index d18f6593..b279d851 100644 --- a/crates/bashkit/tests/script_execution_tests.rs +++ b/crates/bashkit/tests/script_execution_tests.rs @@ -237,9 +237,19 @@ async fn command_not_found_sandbox_hint() { assert_eq!(result.exit_code, 127); assert!(result.stderr.contains("privilege")); + // With ssh feature, ssh is a registered builtin (returns "not configured"). + // Without ssh feature, ssh is unavailable (returns "command not found"). let result = bash.exec("ssh user@host").await.unwrap(); - assert_eq!(result.exit_code, 127); - assert!(result.stderr.contains("curl/wget")); + #[cfg(feature = "ssh")] + { + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("not configured")); + } + #[cfg(not(feature = "ssh"))] + { + assert_eq!(result.exit_code, 127); + assert!(result.stderr.contains("ssh")); + } } /// Completely unknown command has no suggestion diff --git a/crates/bashkit/tests/ssh_builtin_tests.rs b/crates/bashkit/tests/ssh_builtin_tests.rs new file mode 100644 index 00000000..49428e85 --- /dev/null +++ b/crates/bashkit/tests/ssh_builtin_tests.rs @@ -0,0 +1,316 @@ +//! Integration tests for SSH builtins (ssh, scp, sftp). +//! +//! Uses a mock SshHandler so these tests run without network access. + +#[cfg(feature = "ssh")] +mod ssh_builtin_tests { + use async_trait::async_trait; + use bashkit::{Bash, SshConfig, SshHandler, SshOutput, SshTarget}; + + struct RecordingHandler; + + #[async_trait] + impl SshHandler for RecordingHandler { + async fn exec(&self, target: &SshTarget, command: &str) -> Result { + Ok(SshOutput { + stdout: format!( + "user={} host={} cmd={}\n", + target.user, target.host, command + ), + stderr: String::new(), + exit_code: 0, + }) + } + + async fn shell(&self, target: &SshTarget) -> Result { + Ok(SshOutput { + stdout: format!("shell user={} host={}\n", target.user, target.host), + stderr: String::new(), + exit_code: 0, + }) + } + + async fn upload( + &self, + _target: &SshTarget, + remote_path: &str, + content: &[u8], + _mode: u32, + ) -> Result<(), String> { + if remote_path == "/fail" { + return Err("permission denied".to_string()); + } + assert!(!content.is_empty()); + Ok(()) + } + + async fn download( + &self, + _target: &SshTarget, + remote_path: &str, + ) -> Result, String> { + if remote_path == "/missing" { + return Err("no such file".to_string()); + } + Ok(format!("content of {remote_path}\n").into_bytes()) + } + } + + fn bash() -> Bash { + Bash::builder() + .ssh( + SshConfig::new() + .allow("host.example.com") + .allow("*.allowed.co") + .default_user("testuser"), + ) + .ssh_handler(Box::new(RecordingHandler)) + .build() + } + + // ── SSH ── + + #[tokio::test] + async fn ssh_basic_command() { + let mut b = bash(); + let r = b.exec("ssh host.example.com ls -la").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("cmd=ls -la")); + assert!(r.stdout.contains("user=testuser")); + } + + #[tokio::test] + async fn ssh_with_user() { + let mut b = bash(); + let r = b.exec("ssh deploy@host.example.com whoami").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("user=deploy")); + } + + #[tokio::test] + async fn ssh_no_command_opens_shell() { + let mut b = bash(); + let r = b.exec("ssh host.example.com").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert!( + r.stdout + .contains("shell user=testuser host=host.example.com") + ); + } + + #[tokio::test] + async fn ssh_heredoc() { + let mut b = bash(); + let r = b + .exec("ssh host.example.com <<'EOF'\necho hello\nEOF") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("cmd=echo hello")); + } + + #[tokio::test] + async fn ssh_blocked_host() { + let mut b = bash(); + let r = b.exec("ssh evil.com 'id'").await.unwrap(); + assert_ne!(r.exit_code, 0); + assert!(r.stderr.contains("not in allowlist")); + } + + #[tokio::test] + async fn ssh_wildcard_host() { + let mut b = bash(); + let r = b.exec("ssh db.allowed.co uname").await.unwrap(); + assert_eq!(r.exit_code, 0); + assert!(r.stdout.contains("host=db.allowed.co")); + } + + #[tokio::test] + async fn ssh_no_host() { + let mut b = bash(); + let r = b.exec("ssh").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn ssh_port_flag() { + let mut b = Bash::builder() + .ssh( + SshConfig::new() + .allow("host.example.com") + .allow_port(22) + .allow_port(2222) + .default_user("u"), + ) + .ssh_handler(Box::new(RecordingHandler)) + .build(); + let r = b.exec("ssh host.example.com echo ok").await.unwrap(); + assert_eq!(r.exit_code, 0); + let r = b + .exec("ssh -p 2222 host.example.com echo ok") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + let r = b + .exec("ssh -p 3333 host.example.com echo ok") + .await + .unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn ssh_port_flag_missing_arg() { + let mut b = bash(); + let r = b.exec("ssh -p").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn ssh_pipe_output() { + let mut b = bash(); + let r = b + .exec("ssh host.example.com echo hello | tr h H") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + } + + // ── SCP ── + + #[tokio::test] + async fn scp_upload() { + let mut b = bash(); + b.exec("echo 'data' > /tmp/local.txt").await.unwrap(); + let r = b + .exec("scp /tmp/local.txt host.example.com:/remote/path.txt") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + } + + #[tokio::test] + async fn scp_download() { + let mut b = bash(); + let r = b + .exec("scp host.example.com:/etc/config.txt /tmp/downloaded.txt") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + let cat = b.exec("cat /tmp/downloaded.txt").await.unwrap(); + assert!(cat.stdout.contains("content of /etc/config.txt")); + } + + #[tokio::test] + async fn scp_download_missing() { + let mut b = bash(); + let r = b + .exec("scp host.example.com:/missing /tmp/out.txt") + .await + .unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn scp_no_remote() { + let mut b = bash(); + let r = b.exec("scp file1 file2").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn scp_too_few_args() { + let mut b = bash(); + let r = b.exec("scp file1").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn scp_blocked_host() { + let mut b = bash(); + b.exec("echo x > /tmp/f.txt").await.unwrap(); + let r = b.exec("scp /tmp/f.txt evil.com:/tmp/f.txt").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + // ── SFTP ── + + #[tokio::test] + async fn sftp_put() { + let mut b = bash(); + b.exec("echo 'data' > /tmp/upload.txt").await.unwrap(); + let r = b + .exec("sftp host.example.com <<'EOF'\nput /tmp/upload.txt /remote/upload.txt\nEOF") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + } + + #[tokio::test] + async fn sftp_get() { + let mut b = bash(); + let r = b + .exec("sftp host.example.com <<'EOF'\nget /remote/data.txt /tmp/fetched.txt\nEOF") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + let cat = b.exec("cat /tmp/fetched.txt").await.unwrap(); + assert!(cat.stdout.contains("content of /remote/data.txt")); + } + + #[tokio::test] + async fn sftp_ls() { + let mut b = bash(); + let r = b + .exec("sftp host.example.com <<'EOF'\nls /var\nEOF") + .await + .unwrap(); + assert_eq!(r.exit_code, 0); + // Path is shell-escaped (TM-SSH-008) + assert!( + r.stdout.contains("cmd=ls -la"), + "expected ls command in output, got: {}", + r.stdout + ); + } + + #[tokio::test] + async fn sftp_unsupported_command() { + let mut b = bash(); + let r = b + .exec("sftp host.example.com <<'EOF'\nrm /tmp/x\nEOF") + .await + .unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn sftp_no_stdin() { + let mut b = bash(); + let r = b.exec("sftp host.example.com").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + // ── Not configured ── + + #[tokio::test] + async fn ssh_not_configured() { + let mut b = Bash::new(); + let r = b.exec("ssh host.example.com ls").await.unwrap(); + assert_ne!(r.exit_code, 0); + assert!(r.stderr.contains("not configured")); + } + + #[tokio::test] + async fn scp_not_configured() { + let mut b = Bash::new(); + let r = b.exec("scp file host.example.com:/path").await.unwrap(); + assert_ne!(r.exit_code, 0); + } + + #[tokio::test] + async fn sftp_not_configured() { + let mut b = Bash::new(); + let r = b.exec("sftp host.example.com").await.unwrap(); + assert_ne!(r.exit_code, 0); + } +} diff --git a/crates/bashkit/tests/ssh_supabase_tests.rs b/crates/bashkit/tests/ssh_supabase_tests.rs new file mode 100644 index 00000000..9981e390 --- /dev/null +++ b/crates/bashkit/tests/ssh_supabase_tests.rs @@ -0,0 +1,40 @@ +//! Integration tests for `ssh supabase.sh`. +//! +//! Requires `ssh` feature. No credentials needed — supabase.sh is a public SSH service. + +#[cfg(feature = "ssh")] +mod ssh_supabase { + use bashkit::{Bash, SshConfig}; + + fn bash_with_supabase() -> Bash { + Bash::builder() + .ssh(SshConfig::new().allow("supabase.sh")) + .build() + } + + /// Connects to supabase.sh via SSH. Requires network access. + #[tokio::test] + #[ignore] // Requires network — run with: cargo test --features ssh --test ssh_supabase_tests -- --ignored + async fn ssh_supabase_connects() { + let mut bash = bash_with_supabase(); + let result = bash.exec("ssh supabase.sh").await.unwrap(); + eprintln!( + "ssh supabase.sh: exit={} stdout_len={} stderr_len={}", + result.exit_code, + result.stdout.len(), + result.stderr.len() + ); + } + + #[tokio::test] + async fn ssh_blocked_host_rejected() { + let mut bash = bash_with_supabase(); + let result = bash.exec("ssh evil.com 'id'").await.unwrap(); + assert_ne!(result.exit_code, 0); + assert!( + result.stderr.contains("not in allowlist"), + "expected allowlist error, got: {}", + result.stderr + ); + } +} diff --git a/crates/bashkit/tests/threat_model_tests.rs b/crates/bashkit/tests/threat_model_tests.rs index e36512c7..bdb45c61 100644 --- a/crates/bashkit/tests/threat_model_tests.rs +++ b/crates/bashkit/tests/threat_model_tests.rs @@ -1049,11 +1049,12 @@ mod edge_cases { async fn command_not_found_stderr_format() { let mut bash = Bash::new(); - let result = bash.exec("ssh").await.unwrap(); + // Use rsync which is never a builtin (ssh may be with ssh feature) + let result = bash.exec("rsync").await.unwrap(); assert_eq!(result.exit_code, 127); // Should match bash format: "bash: cmd: command not found" assert!( - result.stderr.starts_with("bash: ssh: command not found"), + result.stderr.starts_with("bash: rsync: command not found"), "stderr should match bash format, got: {}", result.stderr ); @@ -1066,7 +1067,8 @@ mod edge_cases { // Commands that are NOT implemented as builtins // Note: git is a builtin (returns exit 1 when not configured, not 127) - for cmd in &["ssh", "apt", "yum", "docker", "vim", "nano"] { + // Note: ssh/scp/sftp are builtins when ssh feature is enabled + for cmd in &["apt", "yum", "docker", "vim", "nano", "rsync"] { let result = bash.exec(cmd).await.unwrap(); assert_eq!( result.exit_code, 127, diff --git a/deny.toml b/deny.toml index a69c31f9..94251182 100644 --- a/deny.toml +++ b/deny.toml @@ -39,6 +39,9 @@ ignore = [ # atomic-polyfill: transitive via monty -> postcard -> heapless # Unmaintained but no security vulnerability; upstream dep we can't control "RUSTSEC-2023-0089", + # rsa: Marvin timing attack — transitive via russh-keys -> ssh-key -> rsa + # Only used for RSA key parsing; no direct timing attack exposure + "RUSTSEC-2023-0071", ] [bans] diff --git a/specs/015-ssh-support.md b/specs/015-ssh-support.md new file mode 100644 index 00000000..c23f1bd6 --- /dev/null +++ b/specs/015-ssh-support.md @@ -0,0 +1,159 @@ +# 015: SSH Support + +## Status + +Phase 1: In Progress — Handler trait, allowlist, ssh/scp/sftp builtins + +## Decision + +Bashkit provides SSH/SCP/SFTP builtins via the `ssh` feature flag. +Follows the same opt-in pattern as `git` and `http_client`. + +### Feature Flag + +Enable with: +```toml +[dependencies] +bashkit = { version = "0.1", features = ["ssh"] } +``` + +Pulls in `russh` + `russh-keys` for the default transport implementation. + +### Configuration + +```rust +use bashkit::{Bash, SshConfig}; + +let bash = Bash::builder() + .ssh(SshConfig::new() + .allow("db.abc123.supabase.co") + .allow("*.example.com") + .default_user("root") + .timeout(Duration::from_secs(30))) + .build(); +``` + +### Supported Commands + +#### Phase 1 — Command Execution + +| Command | Description | +|---------|-------------| +| `ssh [user@]host command...` | Execute command on remote host | +| `ssh -i keyfile [user@]host command...` | With identity file (from VFS) | +| `ssh -p port [user@]host command...` | Custom port | +| `scp source [user@]host:dest` | Copy file to remote | +| `scp [user@]host:source dest` | Copy file from remote | +| `sftp [user@]host` | Interactive-ish file transfer (heredoc/pipe mode) | + +#### Phase 2 — Interactive Sessions (Future) + +| Command | Description | +|---------|-------------| +| `ssh [user@]host` (no command) | Interactive session via heredoc | +| Port forwarding | `-L`, `-R` tunnel support | +| Agent forwarding | `-A` SSH agent support | + +### Architecture + +Follows the HTTP pattern: trait + allowlist + default implementation. + +``` +┌─────────────────────────────────────┐ +│ ssh/scp/sftp builtins │ +│ - Parse CLI args │ +│ - Validate host against allowlist │ +│ - Delegate to SshClient │ +├─────────────────────────────────────┤ +│ SshClient │ +│ - Holds SshConfig + SshHandler │ +│ - Enforces allowlist before calls │ +│ - Manages session pool │ +├─────────────────────────────────────┤ +│ SshHandler trait (pluggable) │ +│ - Default: russh-based impl │ +│ - Custom: mock, proxy, log, etc. │ +├─────────────────────────────────────┤ +│ SshAllowlist │ +│ - Host patterns with glob support │ +│ - Port restrictions │ +│ - Default-deny │ +└─────────────────────────────────────┘ +``` + +### Handler Trait + +```rust +#[async_trait] +pub trait SshHandler: Send + Sync { + /// Execute a command on a remote host. + async fn exec( + &self, + target: &SshTarget, + command: &str, + ) -> std::result::Result; + + /// Upload a file to a remote host (scp/sftp put). + async fn upload( + &self, + target: &SshTarget, + remote_path: &str, + content: &[u8], + mode: u32, + ) -> std::result::Result<(), String>; + + /// Download a file from a remote host (scp/sftp get). + async fn download( + &self, + target: &SshTarget, + remote_path: &str, + ) -> std::result::Result, String>; +} +``` + +### Security Model + +- **Disabled by default**: SSH requires explicit `SshConfig` via builder +- **Host allowlist**: Only allowed hosts can be connected to (default-deny) +- **No credential leakage**: Keys read from VFS only, never from host `~/.ssh/` +- **Resource limits**: Max concurrent sessions, connection timeout, response size +- **No agent forwarding by default**: Must be explicitly enabled +- **Port restrictions**: Configurable allowed ports (default: 22) + +### Threat IDs + +| ID | Threat | Mitigation | +|----|--------|-----------| +| TM-SSH-001 | Unauthorized host access | Host allowlist (default-deny) | +| TM-SSH-002 | Credential leakage | Keys from VFS only, no host ~/.ssh/ | +| TM-SSH-003 | Session exhaustion | Max concurrent sessions limit | +| TM-SSH-004 | Response size bomb | Max response bytes limit | +| TM-SSH-005 | Connection hang | Connect + read timeouts | +| TM-SSH-006 | Host key MITM | Configurable host key verification | +| TM-SSH-007 | Port scanning | Port allowlist | +| TM-SSH-008 | Command injection via args | Shell-escape remote commands | + +### Builder API + +```rust +Bash::builder() + .ssh(SshConfig::new() + .allow("*.supabase.co") // Host glob pattern + .allow_port(22) // Allowed ports (default: 22) + .allow_port(2222) + .default_user("root") + .timeout(Duration::from_secs(30)) + .max_response_bytes(10_000_000) // 10MB + .max_sessions(5)) + .ssh_handler(Box::new(custom_handler)) // Optional custom handler + .build() +``` + +### Allowlist Patterns + +- Exact host: `db.abc123.supabase.co` +- Wildcard subdomain: `*.supabase.co` +- IP address: `192.168.1.100` +- With port override: patterns apply to allowed ports list + +No scheme needed (always SSH protocol). diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 85b89d82..b8c7eeed 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -22,6 +22,18 @@ criteria = "safe-to-deploy" version = "2.0.1" criteria = "safe-to-deploy" +[[exemptions.aead]] +version = "0.5.2" +criteria = "safe-to-deploy" + +[[exemptions.aes]] +version = "0.8.4" +criteria = "safe-to-deploy" + +[[exemptions.aes-gcm]] +version = "0.10.3" +criteria = "safe-to-deploy" + [[exemptions.ahash]] version = "0.8.12" criteria = "safe-to-deploy" @@ -74,6 +86,10 @@ criteria = "safe-to-deploy" version = "0.5.1" criteria = "safe-to-deploy" +[[exemptions.argon2]] +version = "0.5.3" +criteria = "safe-to-deploy" + [[exemptions.async-trait]] version = "0.1.89" criteria = "safe-to-deploy" @@ -102,10 +118,22 @@ criteria = "safe-to-deploy" version = "1.16.2" criteria = "safe-to-deploy" +[[exemptions.base16ct]] +version = "0.2.0" +criteria = "safe-to-deploy" + [[exemptions.base64]] version = "0.22.1" criteria = "safe-to-deploy" +[[exemptions.base64ct]] +version = "1.8.3" +criteria = "safe-to-deploy" + +[[exemptions.bcrypt-pbkdf]] +version = "0.10.0" +criteria = "safe-to-deploy" + [[exemptions.bit-set]] version = "0.8.0" criteria = "safe-to-deploy" @@ -118,10 +146,26 @@ criteria = "safe-to-deploy" version = "2.11.0" criteria = "safe-to-deploy" +[[exemptions.blake2]] +version = "0.10.6" +criteria = "safe-to-deploy" + +[[exemptions.block-buffer]] +version = "0.10.4" +criteria = "safe-to-deploy" + [[exemptions.block-buffer]] version = "0.12.0" criteria = "safe-to-deploy" +[[exemptions.block-padding]] +version = "0.3.3" +criteria = "safe-to-deploy" + +[[exemptions.blowfish]] +version = "0.9.1" +criteria = "safe-to-deploy" + [[exemptions.bstr]] version = "1.12.1" criteria = "safe-to-deploy" @@ -154,6 +198,10 @@ criteria = "safe-to-run" version = "0.2.4" criteria = "safe-to-deploy" +[[exemptions.cbc]] +version = "0.1.2" +criteria = "safe-to-deploy" + [[exemptions.cc]] version = "1.2.58" criteria = "safe-to-deploy" @@ -170,6 +218,10 @@ criteria = "safe-to-deploy" version = "0.2.1" criteria = "safe-to-deploy" +[[exemptions.chacha20]] +version = "0.9.1" +criteria = "safe-to-deploy" + [[exemptions.chrono]] version = "0.4.44" criteria = "safe-to-deploy" @@ -186,6 +238,10 @@ criteria = "safe-to-run" version = "0.2.2" criteria = "safe-to-run" +[[exemptions.cipher]] +version = "0.4.4" +criteria = "safe-to-deploy" + [[exemptions.clap]] version = "4.6.0" criteria = "safe-to-deploy" @@ -234,6 +290,10 @@ criteria = "safe-to-deploy" version = "0.16.3" criteria = "safe-to-run" +[[exemptions.const-oid]] +version = "0.9.6" +criteria = "safe-to-deploy" + [[exemptions.const-oid]] version = "0.10.2" criteria = "safe-to-deploy" @@ -254,6 +314,10 @@ criteria = "safe-to-deploy" version = "0.1.3" criteria = "safe-to-deploy" +[[exemptions.cpufeatures]] +version = "0.2.17" +criteria = "safe-to-deploy" + [[exemptions.cpufeatures]] version = "0.3.0" criteria = "safe-to-deploy" @@ -290,6 +354,14 @@ criteria = "safe-to-run" version = "0.2.4" criteria = "safe-to-run" +[[exemptions.crypto-bigint]] +version = "0.5.5" +criteria = "safe-to-deploy" + +[[exemptions.crypto-common]] +version = "0.1.7" +criteria = "safe-to-deploy" + [[exemptions.crypto-common]] version = "0.2.1" criteria = "safe-to-deploy" @@ -302,6 +374,30 @@ criteria = "safe-to-deploy" version = "0.0.7" criteria = "safe-to-deploy" +[[exemptions.ctr]] +version = "0.9.2" +criteria = "safe-to-deploy" + +[[exemptions.curve25519-dalek]] +version = "4.1.3" +criteria = "safe-to-deploy" + +[[exemptions.curve25519-dalek-derive]] +version = "0.1.1" +criteria = "safe-to-deploy" + +[[exemptions.data-encoding]] +version = "2.10.0" +criteria = "safe-to-deploy" + +[[exemptions.delegate]] +version = "0.13.5" +criteria = "safe-to-deploy" + +[[exemptions.der]] +version = "0.7.10" +criteria = "safe-to-deploy" + [[exemptions.derive-where]] version = "1.6.1" criteria = "safe-to-deploy" @@ -310,6 +406,10 @@ criteria = "safe-to-deploy" version = "0.1.13" criteria = "safe-to-run" +[[exemptions.digest]] +version = "0.10.7" +criteria = "safe-to-deploy" + [[exemptions.digest]] version = "0.11.2" criteria = "safe-to-deploy" @@ -338,10 +438,26 @@ criteria = "safe-to-deploy" version = "1.0.20" criteria = "safe-to-deploy" +[[exemptions.ecdsa]] +version = "0.16.9" +criteria = "safe-to-deploy" + +[[exemptions.ed25519]] +version = "2.2.3" +criteria = "safe-to-deploy" + +[[exemptions.ed25519-dalek]] +version = "2.2.0" +criteria = "safe-to-deploy" + [[exemptions.either]] version = "1.15.0" criteria = "safe-to-deploy" +[[exemptions.elliptic-curve]] +version = "0.13.8" +criteria = "safe-to-deploy" + [[exemptions.embedded-io]] version = "0.4.0" criteria = "safe-to-deploy" @@ -354,6 +470,10 @@ criteria = "safe-to-deploy" version = "1.0.0" criteria = "safe-to-run" +[[exemptions.enum_dispatch]] +version = "0.3.13" +criteria = "safe-to-deploy" + [[exemptions.equivalent]] version = "1.0.2" criteria = "safe-to-deploy" @@ -374,9 +494,13 @@ criteria = "safe-to-deploy" version = "2.3.0" criteria = "safe-to-deploy" -[[exemptions.fastrand]] -version = "2.3.0" -criteria = "safe-to-run" +[[exemptions.ff]] +version = "0.13.1" +criteria = "safe-to-deploy" + +[[exemptions.fiat-crypto]] +version = "0.2.9" +criteria = "safe-to-deploy" [[exemptions.find-msvc-tools]] version = "0.1.9" @@ -442,6 +566,10 @@ criteria = "safe-to-deploy" version = "0.3.32" criteria = "safe-to-deploy" +[[exemptions.generic-array]] +version = "0.14.7" +criteria = "safe-to-deploy" + [[exemptions.get-size-derive2]] version = "0.7.4" criteria = "safe-to-deploy" @@ -466,10 +594,18 @@ criteria = "safe-to-deploy" version = "0.4.2" criteria = "safe-to-run" +[[exemptions.ghash]] +version = "0.5.1" +criteria = "safe-to-deploy" + [[exemptions.globset]] version = "0.4.18" criteria = "safe-to-deploy" +[[exemptions.group]] +version = "0.13.0" +criteria = "safe-to-deploy" + [[exemptions.half]] version = "2.7.1" criteria = "safe-to-run" @@ -494,10 +630,30 @@ criteria = "safe-to-deploy" version = "0.5.0" criteria = "safe-to-deploy" +[[exemptions.hex]] +version = "0.4.3" +criteria = "safe-to-deploy" + +[[exemptions.hex-literal]] +version = "0.4.1" +criteria = "safe-to-deploy" + [[exemptions.hifijson]] version = "0.5.0" criteria = "safe-to-deploy" +[[exemptions.hkdf]] +version = "0.12.4" +criteria = "safe-to-deploy" + +[[exemptions.hmac]] +version = "0.12.1" +criteria = "safe-to-deploy" + +[[exemptions.home]] +version = "0.5.12" +criteria = "safe-to-deploy" + [[exemptions.http]] version = "1.4.0" criteria = "safe-to-deploy" @@ -582,6 +738,10 @@ criteria = "safe-to-deploy" version = "2.13.1" criteria = "safe-to-deploy" +[[exemptions.inout]] +version = "0.1.4" +criteria = "safe-to-deploy" + [[exemptions.insta]] version = "1.47.2" criteria = "safe-to-run" @@ -590,6 +750,10 @@ criteria = "safe-to-run" version = "0.1.13" criteria = "safe-to-deploy" +[[exemptions.internal-russh-forked-ssh-key]] +version = "0.6.10+upstream-0.6.7" +criteria = "safe-to-deploy" + [[exemptions.interpolator]] version = "0.5.0" criteria = "safe-to-deploy" @@ -670,6 +834,10 @@ criteria = "safe-to-deploy" version = "0.3.94" criteria = "safe-to-deploy" +[[exemptions.lazy_static]] +version = "1.5.0" +criteria = "safe-to-deploy" + [[exemptions.leb128fmt]] version = "0.1.0" criteria = "safe-to-deploy" @@ -722,6 +890,10 @@ criteria = "safe-to-deploy" version = "0.11.0" criteria = "safe-to-deploy" +[[exemptions.md5]] +version = "0.7.0" +criteria = "safe-to-deploy" + [[exemptions.memchr]] version = "2.8.0" criteria = "safe-to-deploy" @@ -758,6 +930,10 @@ criteria = "safe-to-deploy" version = "3.2.1" criteria = "safe-to-deploy" +[[exemptions.nix]] +version = "0.29.0" +criteria = "safe-to-deploy" + [[exemptions.nohash-hasher]] version = "0.2.0" criteria = "safe-to-deploy" @@ -770,6 +946,10 @@ criteria = "safe-to-deploy" version = "0.4.6" criteria = "safe-to-deploy" +[[exemptions.num-bigint-dig]] +version = "0.8.6" +criteria = "safe-to-deploy" + [[exemptions.num-complex]] version = "0.4.6" criteria = "safe-to-deploy" @@ -778,6 +958,10 @@ criteria = "safe-to-deploy" version = "0.1.46" criteria = "safe-to-deploy" +[[exemptions.num-iter]] +version = "0.1.45" +criteria = "safe-to-deploy" + [[exemptions.num-rational]] version = "0.4.2" criteria = "safe-to-deploy" @@ -798,6 +982,10 @@ criteria = "safe-to-deploy" version = "11.1.5" criteria = "safe-to-run" +[[exemptions.opaque-debug]] +version = "0.3.1" +criteria = "safe-to-deploy" + [[exemptions.openssl-probe]] version = "0.2.1" criteria = "safe-to-deploy" @@ -870,10 +1058,30 @@ criteria = "safe-to-deploy" version = "0.117.0" criteria = "safe-to-deploy" +[[exemptions.p256]] +version = "0.13.2" +criteria = "safe-to-deploy" + +[[exemptions.p384]] +version = "0.13.1" +criteria = "safe-to-deploy" + +[[exemptions.p521]] +version = "0.13.3" +criteria = "safe-to-deploy" + [[exemptions.page_size]] version = "0.6.0" criteria = "safe-to-run" +[[exemptions.pageant]] +version = "0.0.1" +criteria = "safe-to-deploy" + +[[exemptions.pageant]] +version = "0.0.3" +criteria = "safe-to-deploy" + [[exemptions.papergrid]] version = "0.17.0" criteria = "safe-to-deploy" @@ -886,10 +1094,22 @@ criteria = "safe-to-run" version = "0.9.12" criteria = "safe-to-run" +[[exemptions.password-hash]] +version = "0.5.0" +criteria = "safe-to-deploy" + [[exemptions.paste]] version = "1.0.15" criteria = "safe-to-deploy" +[[exemptions.pbkdf2]] +version = "0.12.2" +criteria = "safe-to-deploy" + +[[exemptions.pem-rfc7468]] +version = "0.7.0" +criteria = "safe-to-deploy" + [[exemptions.percent-encoding]] version = "2.3.2" criteria = "safe-to-deploy" @@ -930,6 +1150,18 @@ criteria = "safe-to-deploy" version = "0.2.17" criteria = "safe-to-deploy" +[[exemptions.pkcs1]] +version = "0.7.5" +criteria = "safe-to-deploy" + +[[exemptions.pkcs5]] +version = "0.7.1" +criteria = "safe-to-deploy" + +[[exemptions.pkcs8]] +version = "0.10.2" +criteria = "safe-to-deploy" + [[exemptions.plotters]] version = "0.3.7" criteria = "safe-to-run" @@ -942,6 +1174,14 @@ criteria = "safe-to-run" version = "0.3.7" criteria = "safe-to-run" +[[exemptions.poly1305]] +version = "0.8.0" +criteria = "safe-to-deploy" + +[[exemptions.polyval]] +version = "0.6.2" +criteria = "safe-to-deploy" + [[exemptions.portable-atomic]] version = "1.13.1" criteria = "safe-to-deploy" @@ -970,6 +1210,10 @@ criteria = "safe-to-run" version = "0.2.37" criteria = "safe-to-deploy" +[[exemptions.primeorder]] +version = "0.13.6" +criteria = "safe-to-deploy" + [[exemptions.proc-macro-error-attr2]] version = "2.0.0" criteria = "safe-to-deploy" @@ -1130,10 +1374,42 @@ criteria = "safe-to-deploy" version = "0.13.2" criteria = "safe-to-deploy" +[[exemptions.rfc6979]] +version = "0.4.0" +criteria = "safe-to-deploy" + [[exemptions.ring]] version = "0.17.14" criteria = "safe-to-deploy" +[[exemptions.rsa]] +version = "0.9.10" +criteria = "safe-to-deploy" + +[[exemptions.russh]] +version = "0.52.1" +criteria = "safe-to-deploy" + +[[exemptions.russh-cryptovec]] +version = "0.48.0" +criteria = "safe-to-deploy" + +[[exemptions.russh-cryptovec]] +version = "0.52.0" +criteria = "safe-to-deploy" + +[[exemptions.russh-keys]] +version = "0.49.2" +criteria = "safe-to-deploy" + +[[exemptions.russh-util]] +version = "0.48.0" +criteria = "safe-to-deploy" + +[[exemptions.russh-util]] +version = "0.52.0" +criteria = "safe-to-deploy" + [[exemptions.rustc-hash]] version = "2.1.2" criteria = "safe-to-deploy" @@ -1186,6 +1462,10 @@ criteria = "safe-to-deploy" version = "0.7.4" criteria = "safe-to-deploy" +[[exemptions.salsa20]] +version = "0.10.2" +criteria = "safe-to-deploy" + [[exemptions.same-file]] version = "1.0.6" criteria = "safe-to-deploy" @@ -1210,10 +1490,18 @@ criteria = "safe-to-deploy" version = "1.2.0" criteria = "safe-to-deploy" +[[exemptions.scrypt]] +version = "0.11.0" +criteria = "safe-to-deploy" + [[exemptions.sdd]] version = "3.0.10" criteria = "safe-to-run" +[[exemptions.sec1]] +version = "0.7.3" +criteria = "safe-to-deploy" + [[exemptions.security-framework]] version = "3.7.0" criteria = "safe-to-deploy" @@ -1262,10 +1550,18 @@ criteria = "safe-to-run" version = "3.4.0" criteria = "safe-to-run" +[[exemptions.sha1]] +version = "0.10.6" +criteria = "safe-to-deploy" + [[exemptions.sha1]] version = "0.11.0" criteria = "safe-to-deploy" +[[exemptions.sha2]] +version = "0.10.9" +criteria = "safe-to-deploy" + [[exemptions.sha2]] version = "0.11.0" criteria = "safe-to-deploy" @@ -1278,6 +1574,10 @@ criteria = "safe-to-deploy" version = "1.4.8" criteria = "safe-to-deploy" +[[exemptions.signature]] +version = "2.2.0" +criteria = "safe-to-deploy" + [[exemptions.simba]] version = "0.9.1" criteria = "safe-to-deploy" @@ -1314,6 +1614,22 @@ criteria = "safe-to-deploy" version = "0.9.8" criteria = "safe-to-deploy" +[[exemptions.spki]] +version = "0.7.3" +criteria = "safe-to-deploy" + +[[exemptions.ssh-cipher]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.ssh-encoding]] +version = "0.2.0" +criteria = "safe-to-deploy" + +[[exemptions.ssh-key]] +version = "0.6.7" +criteria = "safe-to-deploy" + [[exemptions.stable_deref_trait]] version = "1.2.1" criteria = "safe-to-deploy" @@ -1410,18 +1726,10 @@ criteria = "safe-to-deploy" version = "0.1.1" criteria = "safe-to-deploy" -[[exemptions.tokio]] -version = "1.50.0" -criteria = "safe-to-deploy" - [[exemptions.tokio]] version = "1.51.0" criteria = "safe-to-deploy" -[[exemptions.tokio-macros]] -version = "2.6.1" -criteria = "safe-to-deploy" - [[exemptions.tokio-macros]] version = "2.7.0" criteria = "safe-to-deploy" @@ -1432,7 +1740,7 @@ criteria = "safe-to-deploy" [[exemptions.tokio-stream]] version = "0.1.18" -criteria = "safe-to-run" +criteria = "safe-to-deploy" [[exemptions.tokio-test]] version = "0.4.5" @@ -1522,6 +1830,10 @@ criteria = "safe-to-deploy" version = "1.3.0" criteria = "safe-to-deploy" +[[exemptions.universal-hash]] +version = "0.5.1" +criteria = "safe-to-deploy" + [[exemptions.untrusted]] version = "0.9.0" criteria = "safe-to-deploy" @@ -1624,11 +1936,11 @@ criteria = "safe-to-deploy" [[exemptions.winapi]] version = "0.3.9" -criteria = "safe-to-run" +criteria = "safe-to-deploy" [[exemptions.winapi-i686-pc-windows-gnu]] version = "0.4.0" -criteria = "safe-to-run" +criteria = "safe-to-deploy" [[exemptions.winapi-util]] version = "0.1.11" @@ -1636,16 +1948,32 @@ criteria = "safe-to-deploy" [[exemptions.winapi-x86_64-pc-windows-gnu]] version = "0.4.0" -criteria = "safe-to-run" +criteria = "safe-to-deploy" + +[[exemptions.windows]] +version = "0.58.0" +criteria = "safe-to-deploy" + +[[exemptions.windows-core]] +version = "0.58.0" +criteria = "safe-to-deploy" [[exemptions.windows-core]] version = "0.62.2" criteria = "safe-to-deploy" +[[exemptions.windows-implement]] +version = "0.58.0" +criteria = "safe-to-deploy" + [[exemptions.windows-implement]] version = "0.60.2" criteria = "safe-to-deploy" +[[exemptions.windows-interface]] +version = "0.58.0" +criteria = "safe-to-deploy" + [[exemptions.windows-interface]] version = "0.59.3" criteria = "safe-to-deploy" @@ -1654,10 +1982,18 @@ criteria = "safe-to-deploy" version = "0.2.1" criteria = "safe-to-deploy" +[[exemptions.windows-result]] +version = "0.2.0" +criteria = "safe-to-deploy" + [[exemptions.windows-result]] version = "0.4.1" criteria = "safe-to-deploy" +[[exemptions.windows-strings]] +version = "0.1.0" +criteria = "safe-to-deploy" + [[exemptions.windows-strings]] version = "0.5.1" criteria = "safe-to-deploy" @@ -1806,10 +2142,6 @@ criteria = "safe-to-deploy" version = "0.244.0" criteria = "safe-to-deploy" -[[exemptions.writeable]] -version = "0.6.2" -criteria = "safe-to-deploy" - [[exemptions.writeable]] version = "0.6.3" criteria = "safe-to-deploy"