tcrm_task/tasks/
security.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::tasks::error::TaskError;
5const MAX_COMMAND_LEN: usize = 4096;
6const MAX_ARG_LEN: usize = 4096;
7const MAX_WORKING_DIR_LEN: usize = 4096;
8const MAX_ENV_KEY_LEN: usize = 1024;
9const MAX_ENV_VALUE_LEN: usize = 4096;
10/// Security validation utilities for task configuration
11pub struct SecurityValidator;
12
13impl SecurityValidator {
14    /// Validates command name for security and correctness.
15    ///
16    /// # Arguments
17    ///
18    /// * `command` - The command string to validate.
19    ///
20    /// # Returns
21    ///
22    /// - `Ok(())` if the command is valid.
23    /// - `Err(TaskError::InvalidConfiguration)` if the command is invalid.
24    ///
25    /// # Errors
26    ///
27    /// Returns a [`TaskError::InvalidConfiguration`] if:
28    /// - Command is empty or contains only whitespace
29    /// - Command contains null bytes or obvious injection patterns
30    ///
31    /// # Examples
32    /// ```rust
33    /// use tcrm_task::tasks::security::SecurityValidator;
34    ///
35    /// let valid_command = "echo Hello";
36    /// assert!(SecurityValidator::validate_command(valid_command).is_ok());
37    ///
38    /// let invalid_command = "\0";
39    /// assert!(SecurityValidator::validate_command(invalid_command).is_err());
40    /// ```
41    pub fn validate_command(command: &str) -> Result<(), TaskError> {
42        // Check for empty or whitespace-only commands
43        if command.trim().is_empty() {
44            return Err(TaskError::InvalidConfiguration(
45                "Command cannot be empty".to_string(),
46            ));
47        }
48
49        // Only check for obvious injection attempts, not shell features
50        if Self::contains_obvious_injection(command) {
51            return Err(TaskError::InvalidConfiguration(
52                "Command contains potentially dangerous injection patterns".to_string(),
53            ));
54        }
55
56        if command.trim() != command {
57            return Err(TaskError::InvalidConfiguration(
58                "Command cannot have leading or trailing whitespace".to_string(),
59            ));
60        }
61        if command.len() > MAX_COMMAND_LEN {
62            return Err(TaskError::InvalidConfiguration(
63                "Command length exceeds maximum allowed length".to_string(),
64            ));
65        }
66
67        Ok(())
68    }
69
70    /// Validates arguments for security issues.
71    ///
72    /// # Arguments
73    ///
74    /// * `args` - A slice of argument strings to validate.
75    ///
76    /// # Returns
77    ///
78    /// - `Ok(())` if all arguments are valid.
79    /// - `Err(TaskError::InvalidConfiguration)` if any argument is invalid.
80    ///
81    /// # Errors
82    ///
83    /// Returns a [`TaskError::InvalidConfiguration`] if any argument contains null bytes.
84    ///
85    /// # Examples
86    /// ```rust
87    /// use tcrm_task::tasks::security::SecurityValidator;
88    ///
89    /// let valid_args = vec!["arg1".to_string(), "arg2".to_string()];
90    /// assert!(SecurityValidator::validate_args(&valid_args).is_ok());
91    ///
92    /// let invalid_args = vec!["arg1".to_string(), "\0".to_string()];
93    /// assert!(SecurityValidator::validate_args(&invalid_args).is_err());
94    /// ```
95    pub fn validate_args(args: &[String]) -> Result<(), TaskError> {
96        for arg in args {
97            // Only check for null bytes
98            if arg.contains('\0') {
99                return Err(TaskError::InvalidConfiguration(
100                    "Argument contains null characters".to_string(),
101                ));
102            }
103            if arg.is_empty() {
104                return Err(TaskError::InvalidConfiguration(
105                    "Arguments cannot be empty".to_string(),
106                ));
107            }
108            if arg.trim() != arg {
109                return Err(TaskError::InvalidConfiguration(format!(
110                    "Argument '{arg}' cannot have leading/trailing whitespace"
111                )));
112            }
113            if arg.len() > MAX_ARG_LEN {
114                return Err(TaskError::InvalidConfiguration(format!(
115                    "Argument '{arg}' exceeds maximum length"
116                )));
117            }
118        }
119        Ok(())
120    }
121
122    /// Validates the working directory path.
123    ///
124    /// # Arguments
125    ///
126    /// * `dir` - The directory path to validate.
127    ///
128    /// # Returns
129    ///
130    /// - `Ok(())` if the directory is valid.
131    /// - `Err(TaskError::InvalidConfiguration)` if the directory is invalid.
132    ///
133    /// # Errors
134    ///
135    /// Returns a [`TaskError::InvalidConfiguration`] if:
136    /// - Directory does not exist
137    /// - Path exists but is not a directory
138    ///
139    /// # Examples
140    /// ```rust
141    /// use tcrm_task::tasks::security::SecurityValidator;
142    /// use std::env;
143    ///
144    /// // Test with current directory (should exist)
145    /// let current_dir = env::current_dir().unwrap();
146    /// let valid_dir = current_dir.to_str().unwrap();
147    /// assert!(SecurityValidator::validate_working_dir(valid_dir).is_ok());
148    ///
149    /// // Test with nonexistent directory
150    /// let invalid_dir = "nonexistent_dir_12345";
151    /// assert!(SecurityValidator::validate_working_dir(invalid_dir).is_err());
152    /// ```
153    pub fn validate_working_dir(dir: &str) -> Result<(), TaskError> {
154        let path = Path::new(dir);
155
156        // Check if path exists
157        if !path.exists() {
158            return Err(TaskError::InvalidConfiguration(format!(
159                "Working directory does not exist: {dir}"
160            )));
161        }
162
163        // Check if it's actually a directory
164        if !path.is_dir() {
165            return Err(TaskError::InvalidConfiguration(format!(
166                "Working directory is not a directory: {dir}"
167            )));
168        }
169
170        if dir.trim() != dir {
171            return Err(TaskError::InvalidConfiguration(
172                "Working directory cannot have leading/trailing whitespace".to_string(),
173            ));
174        }
175        if dir.len() > MAX_WORKING_DIR_LEN {
176            return Err(TaskError::InvalidConfiguration(
177                "Working directory path exceeds maximum length".to_string(),
178            ));
179        }
180
181        Ok(())
182    }
183
184    /// Validates environment variables for security and correctness.
185    ///
186    /// # Arguments
187    ///
188    /// * `env` - A hashmap of environment variable key-value pairs to validate.
189    ///
190    /// # Returns
191    ///
192    /// - `Ok(())` if all environment variables are valid.
193    /// - `Err(TaskError::InvalidConfiguration)` if any environment variable is invalid.
194    ///
195    /// # Errors
196    ///
197    /// Returns a [`TaskError::InvalidConfiguration`] if:
198    /// - Any environment variable key contains spaces, '=', or null bytes
199    /// - Any environment variable value contains null bytes
200    ///
201    /// # Examples
202    /// ```rust
203    /// use tcrm_task::tasks::security::SecurityValidator;
204    /// use std::collections::HashMap;
205    ///
206    /// let mut valid_env = HashMap::new();
207    /// valid_env.insert("KEY".to_string(), "VALUE".to_string());
208    /// assert!(SecurityValidator::validate_env_vars(&valid_env).is_ok());
209    ///
210    /// let mut invalid_env = HashMap::new();
211    /// invalid_env.insert("KEY\0".to_string(), "VALUE".to_string());
212    /// assert!(SecurityValidator::validate_env_vars(&invalid_env).is_err());
213    /// ```
214    pub fn validate_env_vars(env: &HashMap<String, String>) -> Result<(), TaskError> {
215        for (key, value) in env {
216            // Validate key
217            if key.trim().is_empty() {
218                return Err(TaskError::InvalidConfiguration(
219                    "Environment variable key cannot be empty".to_string(),
220                ));
221            }
222            if key.contains('=') || key.contains('\0') {
223                return Err(TaskError::InvalidConfiguration(
224                    "Environment variable key contains invalid characters".to_string(),
225                ));
226            }
227
228            if key.contains(' ') {
229                return Err(TaskError::InvalidConfiguration(format!(
230                    "Environment variable key '{key}' cannot contain spaces"
231                )));
232            }
233
234            if key.len() > MAX_ENV_KEY_LEN {
235                return Err(TaskError::InvalidConfiguration(format!(
236                    "Environment variable key '{key}' exceeds maximum length"
237                )));
238            }
239
240            // Validate value
241            if value.contains('\0') {
242                return Err(TaskError::InvalidConfiguration(
243                    "Environment variable value contains null characters".to_string(),
244                ));
245            }
246
247            if value.trim() != value {
248                return Err(TaskError::InvalidConfiguration(format!(
249                    "Environment variable '{key}' value cannot have leading/trailing whitespace"
250                )));
251            }
252            if value.len() > MAX_ENV_VALUE_LEN {
253                return Err(TaskError::InvalidConfiguration(format!(
254                    "Environment variable '{key}' value exceeds maximum length"
255                )));
256            }
257        }
258        Ok(())
259    }
260
261    /// Checks for obvious injection attempts while allowing normal shell features.
262    ///
263    /// This internal method identifies clearly malicious patterns without blocking
264    /// legitimate shell functionality. It focuses on patterns that are rarely used
265    /// in normal command execution.
266    ///
267    /// # Arguments
268    ///
269    /// * `input` - The command string to check for injection patterns.
270    ///
271    /// # Returns
272    ///
273    /// `true` if obvious injection patterns are detected, `false` otherwise.
274    fn contains_obvious_injection(input: &str) -> bool {
275        // Only block patterns that are clearly malicious, not functional shell features
276        let obvious_injection_patterns = [
277            "\0",    // Null bytes
278            "\x00",  // Null bytes (hex)
279            "\r\n",  // CRLF injection
280            "eval(", // Direct eval calls
281            "exec(", // Direct exec calls
282        ];
283
284        obvious_injection_patterns
285            .iter()
286            .any(|pattern| input.contains(pattern))
287    }
288
289    /// Validates command with strict security rules for untrusted input sources.
290    ///
291    /// This is an alternative to `validate_command` that blocks all shell features
292    /// and should be used when `TaskConfig` comes from external/untrusted sources.
293    ///
294    /// # Arguments
295    ///
296    /// * `command` - The command string to validate strictly.
297    ///
298    /// # Returns
299    ///
300    /// - `Ok(())` if the command passes strict validation.
301    /// - `Err(TaskError::InvalidConfiguration)` if the command contains potentially dangerous patterns.
302    ///
303    /// # Errors
304    ///
305    /// Returns a [`TaskError::InvalidConfiguration`] if:
306    /// - Command is empty or contains only whitespace
307    /// - Command contains shell metacharacters or redirection operators
308    ///
309    /// # Examples
310    /// ```rust
311    /// use tcrm_task::tasks::security::SecurityValidator;
312    ///
313    /// // Simple command should pass
314    /// let simple_command = "echo";
315    /// assert!(SecurityValidator::validate_command_strict(simple_command).is_ok());
316    ///
317    /// // Command with shell features should fail
318    /// let shell_command = "echo hello; rm -rf /";
319    /// assert!(SecurityValidator::validate_command_strict(shell_command).is_err());
320    /// ```
321    #[allow(dead_code)]
322    pub fn validate_command_strict(command: &str) -> Result<(), TaskError> {
323        // Check for empty or whitespace-only commands
324        if command.trim().is_empty() {
325            return Err(TaskError::InvalidConfiguration(
326                "Command cannot be empty".to_string(),
327            ));
328        }
329
330        // Strict validation - blocks shell features
331        let dangerous_patterns = [
332            ";", "&", "|", "`", "$", "(", ")", "{", "}", "[", "]", "<", ">", "&&", "||", ">>",
333            "<<", "\n", "\r",
334        ];
335
336        if dangerous_patterns
337            .iter()
338            .any(|pattern| command.contains(pattern))
339        {
340            return Err(TaskError::InvalidConfiguration(
341                "Command contains shell metacharacters (use validate_command for developer tools)"
342                    .to_string(),
343            ));
344        }
345
346        Ok(())
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn validate_command_rejects_empty() {
356        assert!(SecurityValidator::validate_command("").is_err());
357        assert!(SecurityValidator::validate_command("   ").is_err());
358    }
359
360    #[test]
361    fn validate_command_accepts_shell_features() {
362        // These should be allowed for developer tools
363        let shell_commands = [
364            "ls | grep pattern",
365            "echo hello > output.txt",
366            "make && npm test",
367            "command1; command2",
368            "echo $PATH",
369            "ls $(pwd)",
370            "cat file.txt | head -n 10",
371        ];
372
373        for cmd in &shell_commands {
374            assert!(
375                SecurityValidator::validate_command(cmd).is_ok(),
376                "Should accept shell feature: {}",
377                cmd
378            );
379        }
380    }
381
382    #[test]
383    fn validate_command_rejects_obvious_injection() {
384        let dangerous_commands = [
385            "command\0with\0nulls",
386            "eval(malicious_code)",
387            "exec(rm -rf /)",
388            "command\r\necho injected",
389        ];
390
391        for cmd in &dangerous_commands {
392            assert!(
393                SecurityValidator::validate_command(cmd).is_err(),
394                "Should reject obvious injection: {}",
395                cmd
396            );
397        }
398    }
399
400    #[test]
401    fn validate_command_strict_blocks_shell_features() {
402        let shell_commands = [
403            "ls | grep pattern",
404            "echo hello > output.txt",
405            "make && npm test",
406        ];
407
408        for cmd in &shell_commands {
409            assert!(
410                SecurityValidator::validate_command_strict(cmd).is_err(),
411                "Strict validation should reject: {}",
412                cmd
413            );
414        }
415    }
416
417    #[test]
418    fn validate_command_accepts_safe_commands() {
419        let safe_commands = [
420            "echo",
421            "ls",
422            "cat",
423            "grep",
424            "node",
425            "python",
426            "ls -la",
427            "grep pattern file.txt",
428            "node script.js",
429        ];
430
431        for cmd in &safe_commands {
432            assert!(
433                SecurityValidator::validate_command(cmd).is_ok(),
434                "Should accept: {}",
435                cmd
436            );
437        }
438    }
439
440    #[test]
441    fn validate_args_accepts_normal_args() {
442        let normal_args = vec![
443            "arg1".to_string(),
444            "--flag".to_string(),
445            "file.txt".to_string(),
446            "path/to/file".to_string(),
447        ];
448
449        assert!(SecurityValidator::validate_args(&normal_args).is_ok());
450    }
451
452    #[test]
453    fn validate_args_rejects_null_bytes() {
454        let dangerous_args = vec!["arg\0with\0nulls".to_string()];
455
456        assert!(SecurityValidator::validate_args(&dangerous_args).is_err());
457    }
458
459    #[test]
460    fn validate_working_dir_accepts_relative_paths() {
461        // Should accept relative paths including .. for developer use
462        let current_dir = std::env::current_dir().unwrap();
463        assert!(SecurityValidator::validate_working_dir(current_dir.to_str().unwrap()).is_ok());
464    }
465
466    #[test]
467    fn validate_working_dir_rejects_nonexistent() {
468        assert!(SecurityValidator::validate_working_dir("/nonexistent/path").is_err());
469    }
470
471    #[test]
472    fn validate_env_vars_rejects_spaces_in_keys() {
473        // Environment variable keys should not contain spaces
474        let mut env = HashMap::new();
475        env.insert("KEY WITH SPACE".to_string(), "value".to_string());
476        assert!(SecurityValidator::validate_env_vars(&env).is_err());
477    }
478
479    #[test]
480    fn validate_env_vars_accepts_normal_vars() {
481        // Normal environment variables should be accepted
482        let mut env = HashMap::new();
483        env.insert("PATH".to_string(), "/usr/bin:/bin".to_string());
484        env.insert(
485            "CUSTOM_VAR".to_string(),
486            "some value with spaces".to_string(),
487        );
488        assert!(SecurityValidator::validate_env_vars(&env).is_ok());
489    }
490
491    #[test]
492    fn validate_env_vars_rejects_invalid_keys() {
493        let mut env = HashMap::new();
494        env.insert("KEY=BAD".to_string(), "value".to_string());
495        assert!(SecurityValidator::validate_env_vars(&env).is_err());
496    }
497
498    #[test]
499    fn validate_env_vars_rejects_null_chars() {
500        let mut env = HashMap::new();
501        env.insert("KEY".to_string(), "value\0with\0nulls".to_string());
502        assert!(SecurityValidator::validate_env_vars(&env).is_err());
503    }
504}