ggen_core/security/
command.rs

1//! Safe command execution to prevent command injection attacks
2//!
3//! **SECURITY ISSUE 3: Command Injection Prevention**
4//!
5//! This module provides safe wrappers around `std::process::Command` to prevent
6//! command injection attacks by avoiding shell execution and validating inputs.
7
8use ggen_utils::error::{Error, Result};
9use std::path::Path;
10use std::process::{Command, Output};
11
12/// Error type for command execution failures
13#[derive(Debug, thiserror::Error)]
14pub enum CommandError {
15    #[error("Invalid command: {0}")]
16    InvalidCommand(String),
17
18    #[error("Command not allowed: {0}")]
19    NotAllowed(String),
20
21    #[error("Command execution failed: {0}")]
22    ExecutionFailed(String),
23
24    #[error("Invalid argument: {0}")]
25    InvalidArgument(String),
26
27    #[error("Command output invalid UTF-8")]
28    InvalidUtf8,
29}
30
31impl From<CommandError> for Error {
32    fn from(err: CommandError) -> Self {
33        Error::new(&err.to_string())
34    }
35}
36
37/// Safe command builder that prevents injection attacks
38///
39/// Instead of executing commands through a shell (vulnerable to injection),
40/// this executes programs directly with validated arguments.
41///
42/// # Security Features
43///
44/// - No shell execution (prevents `; rm -rf /` attacks)
45/// - Argument validation (prevents metacharacter injection)
46/// - Whitelist of allowed commands
47/// - Path validation for file operations
48///
49/// # Examples
50///
51/// ```rust
52/// use ggen_core::security::command::SafeCommand;
53///
54/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
55/// // Safe: Direct program execution
56/// let output = SafeCommand::new("git")?
57///     .arg("init")?
58///     .execute()?;
59///
60/// // Unsafe equivalent that we prevent:
61/// // Command::new("sh").arg("-c").arg(user_input) // VULNERABLE!
62/// # Ok(())
63/// # }
64/// ```
65#[derive(Clone)]
66pub struct SafeCommand {
67    program: String,
68    args: Vec<String>,
69    current_dir: Option<String>,
70}
71
72impl SafeCommand {
73    /// Allowed commands whitelist
74    const ALLOWED_COMMANDS: &'static [&'static str] =
75        &["git", "cargo", "npm", "node", "rustc", "rustup"];
76
77    /// Dangerous shell metacharacters to reject
78    const DANGEROUS_CHARS: &'static [char] = &[
79        ';', '|', '&', '$', '`', '\n', '\r', '<', '>', '(', ')', '{', '}',
80    ];
81
82    /// Create a new safe command
83    ///
84    /// # Security
85    ///
86    /// - Validates command is in whitelist
87    /// - Prevents shell execution
88    /// - Rejects commands with dangerous characters
89    pub fn new(program: &str) -> Result<Self> {
90        // Validate program name
91        if program.is_empty() {
92            return Err(CommandError::InvalidCommand("Empty command".to_string()).into());
93        }
94
95        // Check for dangerous characters
96        if program.chars().any(|c| Self::DANGEROUS_CHARS.contains(&c)) {
97            return Err(CommandError::InvalidCommand(format!(
98                "Command contains dangerous characters: {}",
99                program
100            ))
101            .into());
102        }
103
104        // Check whitelist
105        if !Self::ALLOWED_COMMANDS.contains(&program) {
106            return Err(CommandError::NotAllowed(format!(
107                "Command '{}' is not in allowed list",
108                program
109            ))
110            .into());
111        }
112
113        Ok(Self {
114            program: program.to_string(),
115            args: Vec::new(),
116            current_dir: None,
117        })
118    }
119
120    /// Add an argument to the command
121    ///
122    /// # Security
123    ///
124    /// - Validates argument doesn't contain shell metacharacters
125    /// - Prevents injection via argument expansion
126    pub fn arg(mut self, arg: &str) -> Result<Self> {
127        // Validate argument
128        if arg.chars().any(|c| Self::DANGEROUS_CHARS.contains(&c)) {
129            return Err(CommandError::InvalidArgument(format!(
130                "Argument contains dangerous characters: {}",
131                arg
132            ))
133            .into());
134        }
135
136        self.args.push(arg.to_string());
137        Ok(self)
138    }
139
140    /// Add multiple arguments
141    pub fn args<I, S>(mut self, args: I) -> Result<Self>
142    where
143        I: IntoIterator<Item = S>,
144        S: AsRef<str>,
145    {
146        for arg in args {
147            self = self.arg(arg.as_ref())?;
148        }
149        Ok(self)
150    }
151
152    /// Set the working directory
153    ///
154    /// # Security
155    ///
156    /// - Validates path exists and is a directory
157    /// - Prevents path traversal attacks
158    pub fn current_dir(mut self, dir: &Path) -> Result<Self> {
159        // Validate directory exists
160        if !dir.exists() {
161            return Err(Error::new(&format!(
162                "Directory does not exist: {}",
163                dir.display()
164            )));
165        }
166
167        if !dir.is_dir() {
168            return Err(Error::new(&format!(
169                "Path is not a directory: {}",
170                dir.display()
171            )));
172        }
173
174        // Store as string for later use
175        self.current_dir = Some(dir.to_string_lossy().to_string());
176        Ok(self)
177    }
178
179    /// Execute the command safely
180    ///
181    /// # Security
182    ///
183    /// - Uses direct program execution (no shell)
184    /// - All arguments are properly escaped
185    /// - Output is validated as UTF-8
186    pub fn execute(self) -> Result<Output> {
187        let mut cmd = Command::new(&self.program);
188
189        // Add arguments
190        for arg in &self.args {
191            cmd.arg(arg);
192        }
193
194        // Set working directory if specified
195        if let Some(dir) = &self.current_dir {
196            cmd.current_dir(dir);
197        }
198
199        // Execute command
200        let output = cmd
201            .output()
202            .map_err(|e| CommandError::ExecutionFailed(format!("{}: {}", self.program, e)))?;
203
204        Ok(output)
205    }
206
207    /// Execute and return stdout as String
208    ///
209    /// # Security
210    ///
211    /// - Validates output is valid UTF-8
212    /// - Returns error on non-zero exit code
213    pub fn execute_stdout(self) -> Result<String> {
214        let program = self.program.clone();
215        let output = self.execute()?;
216
217        if !output.status.success() {
218            let stderr = String::from_utf8_lossy(&output.stderr);
219            return Err(CommandError::ExecutionFailed(format!("{}: {}", program, stderr)).into());
220        }
221
222        String::from_utf8(output.stdout).map_err(|_| CommandError::InvalidUtf8.into())
223    }
224}
225
226/// Command executor with additional safety features
227pub struct CommandExecutor;
228
229impl CommandExecutor {
230    /// Execute a git command safely
231    pub fn git(args: &[&str]) -> Result<Output> {
232        let mut cmd = SafeCommand::new("git")?;
233        for arg in args {
234            cmd = cmd.arg(arg)?;
235        }
236        cmd.execute()
237    }
238
239    /// Execute a cargo command safely
240    pub fn cargo(args: &[&str]) -> Result<Output> {
241        let mut cmd = SafeCommand::new("cargo")?;
242        for arg in args {
243            cmd = cmd.arg(arg)?;
244        }
245        cmd.execute()
246    }
247
248    /// Execute an npm command safely
249    pub fn npm(args: &[&str]) -> Result<Output> {
250        let mut cmd = SafeCommand::new("npm")?;
251        for arg in args {
252            cmd = cmd.arg(arg)?;
253        }
254        cmd.execute()
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_safe_command_new_validates_whitelist() {
264        // Allowed command
265        assert!(SafeCommand::new("git").is_ok());
266        assert!(SafeCommand::new("cargo").is_ok());
267
268        // Disallowed command
269        assert!(SafeCommand::new("rm").is_err());
270        assert!(SafeCommand::new("sh").is_err());
271    }
272
273    #[test]
274    fn test_safe_command_rejects_dangerous_chars() {
275        // Dangerous characters in command
276        assert!(SafeCommand::new("git; rm -rf /").is_err());
277        assert!(SafeCommand::new("git | cat").is_err());
278        assert!(SafeCommand::new("git && ls").is_err());
279
280        // Dangerous characters in arguments
281        let cmd1 = SafeCommand::new("git").unwrap();
282        assert!(cmd1.arg("init; rm -rf /").is_err());
283
284        let cmd2 = SafeCommand::new("git").unwrap();
285        assert!(cmd2.arg("init | cat").is_err());
286    }
287
288    #[test]
289    fn test_safe_command_arg_validation() {
290        let cmd = SafeCommand::new("git").unwrap();
291
292        // Safe arguments
293        assert!(cmd.clone().arg("init").is_ok());
294        assert!(cmd.clone().arg("status").is_ok());
295
296        // Dangerous arguments
297        assert!(cmd.clone().arg("init; ls").is_err());
298        assert!(cmd.clone().arg("$(whoami)").is_err());
299        assert!(cmd.clone().arg("`whoami`").is_err());
300    }
301
302    #[test]
303    fn test_command_injection_prevention() {
304        // Attempt command injection via arguments
305        let result = SafeCommand::new("git")
306            .unwrap()
307            .arg("init")
308            .unwrap()
309            .arg("; rm -rf /");
310
311        assert!(result.is_err());
312
313        // Attempt command injection via command name
314        let result = SafeCommand::new("git; whoami");
315        assert!(result.is_err());
316    }
317
318    #[test]
319    fn test_executor_git() {
320        // This will fail if git is not installed, but tests the API
321        let result = CommandExecutor::git(&["--version"]);
322        // Just ensure it returns a Result
323        let _ = result;
324    }
325}