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}