Skip to main content

ssh_cli/ssh/
packing.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Safe packing of `sudo`/`su` commands for one-shot multi-host LLM flows.
5//!
6//! Builds **remote** `sh -c` strings with shell-safe single-quote escaping for
7//! compound commands sent over the SSH channel (`channel.exec`), **not** local
8//! `std::process::Command` spawns.
9//!
10//! # External process boundary (G-PROC)
11//!
12//! - Local product code never invokes `sh`/`sudo`/`su` via `Command`.
13//! - Remote packing is intentional: elevation must run on the target host shell.
14//! - Secrets go on channel stdin (`sudo -S` / `su`), never in argv / command text.
15//! - Callers must pass payloads already rejected for NUL (`validate_command_length`).
16
17use secrecy::{ExposeSecret, SecretString};
18use zeroize::Zeroize;
19
20/// Escapes a string for safe use inside shell single quotes.
21///
22/// Strategy: wrap in single quotes and escape inner single quotes
23/// with the sequence `'\''` (close quote, backslash-quote, open quote).
24#[must_use]
25pub fn escape_shell_single_quotes(value: &str) -> String {
26    let mut result = String::with_capacity(value.len() + 2);
27    result.push('\'');
28    for ch in value.chars() {
29        if ch == '\'' {
30            result.push_str("'\\''");
31        } else {
32            result.push(ch);
33        }
34    }
35    result.push('\'');
36    result
37}
38
39/// Appends `description` as a shell comment safely.
40#[must_use]
41pub fn append_description(command: &str, description: Option<&str>) -> String {
42    match description {
43        Some(d) if !d.trim().is_empty() => {
44            let cleaned = d.replace(['\n', '\r'], " ");
45            format!("{command} # {cleaned}")
46        }
47        _ => command.to_string(),
48    }
49}
50
51/// Packing result: remote command **without** secret in argv + optional bytes
52/// to send on the SSH channel stdin (GAP-SSH-SEC-001).
53///
54/// `stdin` may hold a password; [`Drop`] zeroizes it (memory / RAII rule).
55/// Debug redacts stdin. Prefer moving `stdin` into `run_command` (which also
56/// zeroizes after the channel write).
57#[derive(Clone)]
58pub struct PackedCommand {
59    /// Remote command line (no embedded password).
60    pub command: String,
61    /// Bytes to write on channel stdin (e.g. password + `\n` for `sudo -S` / `su`).
62    pub stdin: Option<Vec<u8>>,
63}
64
65impl std::fmt::Debug for PackedCommand {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("PackedCommand")
68            .field("command", &self.command)
69            .field("stdin", &self.stdin.as_ref().map(|_| "<redacted bytes>"))
70            .finish()
71    }
72}
73
74impl Drop for PackedCommand {
75    fn drop(&mut self) {
76        if let Some(ref mut bytes) = self.stdin {
77            bytes.zeroize();
78        }
79    }
80}
81
82impl PackedCommand {
83    /// Moves stdin out for the channel write; remaining drop is a no-op.
84    ///
85    /// Prefer this over field access: `Drop` prevents partial moves of `stdin`.
86    #[must_use]
87    pub fn take_stdin(&mut self) -> Option<Vec<u8>> {
88        self.stdin.take()
89    }
90}
91
92/// Packs a command for `sudo` with `sh -c`.
93///
94/// - With password: `sudo -S -p '' sh -c 'cmd'` and password on the **channel stdin** (not argv).
95/// - Without password: `sudo -n sh -c 'cmd'`.
96#[must_use]
97pub fn pack_sudo(command: &str, sudo_password: Option<&SecretString>) -> PackedCommand {
98    let cmd_esc = escape_shell_single_quotes(command);
99    match sudo_password {
100        Some(password) => {
101            let mut stdin = password.expose_secret().as_bytes().to_vec();
102            stdin.push(b'\n');
103            PackedCommand {
104                command: format!("sudo -S -p '' sh -c {cmd_esc}"),
105                stdin: Some(stdin),
106            }
107        }
108        None => PackedCommand {
109            command: format!("sudo -n sh -c {cmd_esc}"),
110            stdin: None,
111        },
112    }
113}
114
115/// Packs a command for `su - -c` one-shot; password goes on the channel stdin.
116#[must_use]
117pub fn pack_su(command: &str, su_password: &SecretString) -> PackedCommand {
118    let cmd_esc = escape_shell_single_quotes(command);
119    let mut stdin = su_password.expose_secret().as_bytes().to_vec();
120    stdin.push(b'\n');
121    PackedCommand {
122        command: format!("su - -c {cmd_esc}"),
123        stdin: Some(stdin),
124    }
125}
126
127/// Random bytes behind a remote job marker.
128///
129/// 128 bits make an accidental collision between two concurrent invocations
130/// impossible in practice, which is what keeps `pkill -f` from reaching a
131/// process this invocation does not own.
132const ABORT_MARKER_RANDOM_BYTES: usize = 16;
133
134/// Fixed, greppable prefix of the remote job marker.
135///
136/// The prefix exists for humans reading `ps` output on the target host; the
137/// uniqueness comes from the random suffix, never from the prefix.
138const ABORT_MARKER_PREFIX: &str = "sshcli-job-";
139
140/// Lowercase hex alphabet used to render the marker without a formatting machinery.
141const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
142
143/// Mints a per-invocation marker to be embedded in the remote command line.
144///
145/// A5: the previous abort path derived the `pkill -f` pattern from the *command
146/// text*, which for elevated runs degraded to the literal `sudo -S -p`. That
147/// pattern matches every `sudo` process on the target host, so a local timeout
148/// killed unrelated sessions of other users and of concurrent `ssh-cli` runs.
149/// A marker that only this process knows makes the kill self-scoped.
150///
151/// The marker is derived from the OS CSPRNG, never from the clock: two
152/// invocations started in the same millisecond must still not collide, and a
153/// skewed client clock must not be able to make one invocation adopt another's
154/// identity.
155///
156/// Returns `None` when the CSPRNG is unavailable; callers must then skip the
157/// remote abort entirely rather than fall back to a guessable identifier.
158#[must_use]
159pub fn new_remote_job_marker() -> Option<String> {
160    let mut raw = [0u8; ABORT_MARKER_RANDOM_BYTES];
161    if getrandom::fill(&mut raw).is_err() {
162        return None;
163    }
164    let mut marker = String::with_capacity(ABORT_MARKER_PREFIX.len() + raw.len() * 2);
165    marker.push_str(ABORT_MARKER_PREFIX);
166    for byte in raw {
167        marker.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
168        marker.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
169    }
170    Some(marker)
171}
172
173/// True when `value` has the shape produced by [`new_remote_job_marker`].
174///
175/// Used as a guard before building a kill command: an abort pattern that is not
176/// a marker would widen the blast radius back to arbitrary command text.
177#[must_use]
178pub fn is_remote_job_marker(value: &str) -> bool {
179    value.strip_prefix(ABORT_MARKER_PREFIX).is_some_and(|hex| {
180        hex.len() == ABORT_MARKER_RANDOM_BYTES * 2 && hex.bytes().all(|b| b.is_ascii_hexdigit())
181    })
182}
183
184/// Wraps `command` so the remote process carries `marker` in its argv.
185///
186/// `sh -c '<command>' '<marker>'` runs the command unchanged and binds the
187/// marker to `$0`, which is what lands in `/proc/<pid>/cmdline` and therefore
188/// what `pkill -f` can match. The marker is not a secret and never carries one.
189///
190/// Caveat kept deliberately: only processes whose argv contains the marker are
191/// reachable by the abort, i.e. the wrapper shell and anything that inherits the
192/// text. Grandchildren that re-exec with a fresh argv survive. Leaking a stray
193/// child is strictly safer than the previous behaviour of killing third-party
194/// processes.
195#[must_use]
196pub fn wrap_with_abort_marker(command: &str, marker: &str) -> String {
197    let cmd_esc = escape_shell_single_quotes(command);
198    let marker_esc = escape_shell_single_quotes(marker);
199    format!("sh -c {cmd_esc} {marker_esc}")
200}
201
202/// Sanitizes a command fragment for best-effort use with `pkill -f`.
203///
204/// Kept for callers that need the sanitizer itself. It is **no longer** used to
205/// build remote aborts: a pattern taken from user command text matches foreign
206/// processes (see [`new_remote_job_marker`]). Accepts alphanumerics and a
207/// restricted symbol set; stops at the first dangerous metacharacter. Requires
208/// at least 3 characters. Never embeds passwords (pattern only).
209#[must_use]
210pub fn remote_abort_pattern(command: &str) -> Option<String> {
211    let mut cleaned = String::with_capacity(command.len().min(128));
212    for ch in command.chars().take(128) {
213        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ' ' | ':' | '=') {
214            cleaned.push(ch);
215        } else {
216            break;
217        }
218    }
219    // Avoid a second heap string when trim does not shrink `cleaned`.
220    let trimmed = cleaned.trim();
221    if trimmed.len() < 3 {
222        None
223    } else if trimmed.len() == cleaned.len() {
224        Some(cleaned)
225    } else {
226        Some(trimmed.to_string())
227    }
228}
229
230/// Builds a best-effort remote abort command (TERM, then KILL).
231///
232/// Does not embed secrets. `pattern` must be a marker from
233/// [`new_remote_job_marker`]; anything else re-opens A5 by matching processes
234/// this invocation does not own.
235#[must_use]
236pub fn pack_abort_pkill(pattern: &str) -> String {
237    let esc = escape_shell_single_quotes(pattern);
238    format!(
239        "(pkill -TERM -f {esc} 2>/dev/null || true); sleep 0.2; (pkill -KILL -f {esc} 2>/dev/null || true)"
240    )
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn escape_single_quote() {
249        assert_eq!(escape_shell_single_quotes("ab'cd"), "'ab'\\''cd'");
250        assert_eq!(escape_shell_single_quotes("abc"), "'abc'");
251    }
252
253    #[test]
254    fn sudo_with_password_uses_sh_c_no_secret_in_argv() {
255        let password = SecretString::from("s3cr3t".to_string());
256        let pack = pack_sudo("echo hi | tee /tmp/x", Some(&password));
257        assert!(pack.command.contains("sudo -S -p '' sh -c"));
258        assert!(!pack.command.contains("s3cr3t"));
259        assert!(!pack.command.contains("printf"));
260        let mut pack = pack;
261        let stdin = pack.take_stdin().expect("stdin with password");
262        assert_eq!(stdin, b"s3cr3t\n");
263    }
264
265    #[test]
266    fn sudo_without_password_uses_n() {
267        let pack = pack_sudo("id", None);
268        assert_eq!(pack.command, "sudo -n sh -c 'id'");
269        assert!(pack.stdin.is_none());
270    }
271
272    #[test]
273    fn su_pack_no_secret_in_argv() {
274        let password = SecretString::from("rootpw".to_string());
275        let pack = pack_su("whoami", &password);
276        assert!(pack.command.contains("su - -c"));
277        assert!(!pack.command.contains("rootpw"));
278        assert_eq!(pack.stdin.as_deref(), Some(b"rootpw\n".as_slice()));
279    }
280
281    #[test]
282    fn description_appends_comment() {
283        assert_eq!(
284            append_description("ls", Some("lista arquivos")),
285            "ls # lista arquivos"
286        );
287        assert_eq!(append_description("ls", None), "ls");
288    }
289
290    #[test]
291    fn debug_redacts_stdin() {
292        let password = SecretString::from("s3cr3t".to_string());
293        let pack = pack_sudo("id", Some(&password));
294        let dbg = format!("{pack:?}");
295        assert!(!dbg.contains("s3cr3t"));
296        assert!(dbg.contains("<redacted bytes>"));
297    }
298
299    #[test]
300    fn abort_pattern_sanitizes() {
301        assert_eq!(
302            remote_abort_pattern("sleep 999"),
303            Some("sleep 999".to_string())
304        );
305        // GAP-SSH-TEST-003: dangerous metacharacter → reject (not a tautology).
306        assert_eq!(remote_abort_pattern("$(rm -rf)"), None);
307        assert!(remote_abort_pattern("ab").is_none());
308    }
309
310    #[test]
311    fn job_markers_are_unique_per_invocation() {
312        // A5: the abort of one invocation must not reach another invocation.
313        let a = new_remote_job_marker().expect("csprng available");
314        let b = new_remote_job_marker().expect("csprng available");
315        assert_ne!(a, b);
316        assert!(is_remote_job_marker(&a) && is_remote_job_marker(&b));
317
318        let kill_a = pack_abort_pkill(&a);
319        let kill_b = pack_abort_pkill(&b);
320        // Neither kill command carries the other invocation's marker, so the
321        // remote `pkill -f` cannot select the foreign process.
322        assert!(!kill_a.contains(&b));
323        assert!(!kill_b.contains(&a));
324
325        let cmd_a = wrap_with_abort_marker("sleep 999", &a);
326        let cmd_b = wrap_with_abort_marker("sleep 999", &b);
327        assert!(cmd_a.contains(&a) && !cmd_a.contains(&b));
328        assert!(cmd_b.contains(&b) && !cmd_b.contains(&a));
329    }
330
331    #[test]
332    fn abort_marker_never_matches_third_party_sudo() {
333        // A5 regression: the old pattern degraded to `sudo -S -p`, which
334        // `pkill -f` matches on every sudo process of every user.
335        let password = SecretString::from("s3cr3t".to_string());
336        let pack = pack_sudo("systemctl restart nginx", Some(&password));
337        let marker = new_remote_job_marker().expect("csprng available");
338        let kill = pack_abort_pkill(&marker);
339        assert!(!kill.contains("sudo"));
340        assert!(kill.contains(&marker));
341
342        // The wrapper keeps the secret off the remote command line.
343        let wrapped = wrap_with_abort_marker(&pack.command, &marker);
344        assert!(!wrapped.contains("s3cr3t"));
345        assert!(wrapped.contains(&marker));
346    }
347
348    #[test]
349    fn marker_shape_is_validated() {
350        assert!(!is_remote_job_marker("sudo -S -p"));
351        assert!(!is_remote_job_marker("sshcli-job-"));
352        assert!(!is_remote_job_marker("sshcli-job-zz"));
353    }
354}