Skip to main content

wrkflw_runtime/
sandbox.rs

1use regex::Regex;
2use std::collections::HashSet;
3use std::path::Path;
4use std::process::{Command, Stdio};
5use std::time::Duration;
6use wrkflw_logging;
7
8/// Configuration for sandbox execution.
9///
10/// Note: this sandbox provides **command-level** validation (whitelist,
11/// blocklist, dangerous-pattern regexes) and **environment-variable**
12/// filtering. It does NOT provide filesystem isolation — the command runs
13/// in the caller's working directory as a plain subprocess, so absolute
14/// paths and `..` sequences can reach anything the host user can reach.
15/// If filesystem isolation is needed, use the Docker or Podman runtime.
16#[derive(Debug, Clone)]
17pub struct SandboxConfig {
18    /// Maximum execution time for commands
19    pub max_execution_time: Duration,
20    /// Maximum memory usage in MB
21    pub max_memory_mb: u64,
22    /// Maximum CPU usage percentage
23    pub max_cpu_percent: u64,
24    /// Allowed commands (whitelist)
25    pub allowed_commands: HashSet<String>,
26    /// Blocked commands (blacklist)
27    pub blocked_commands: HashSet<String>,
28    /// Whether to enable network access
29    pub allow_network: bool,
30    /// Maximum number of processes
31    pub max_processes: u32,
32    /// Whether to enable strict mode (more restrictive)
33    pub strict_mode: bool,
34}
35
36impl Default for SandboxConfig {
37    fn default() -> Self {
38        let mut allowed_commands = HashSet::new();
39
40        // Basic safe commands
41        allowed_commands.insert("echo".to_string());
42        allowed_commands.insert("printf".to_string());
43        allowed_commands.insert("cat".to_string());
44        allowed_commands.insert("head".to_string());
45        allowed_commands.insert("tail".to_string());
46        allowed_commands.insert("grep".to_string());
47        allowed_commands.insert("sed".to_string());
48        allowed_commands.insert("awk".to_string());
49        allowed_commands.insert("sort".to_string());
50        allowed_commands.insert("uniq".to_string());
51        allowed_commands.insert("wc".to_string());
52        allowed_commands.insert("cut".to_string());
53        allowed_commands.insert("tr".to_string());
54        allowed_commands.insert("which".to_string());
55        allowed_commands.insert("pwd".to_string());
56        allowed_commands.insert("env".to_string());
57        allowed_commands.insert("date".to_string());
58        allowed_commands.insert("basename".to_string());
59        allowed_commands.insert("dirname".to_string());
60
61        // File operations (safe variants)
62        allowed_commands.insert("ls".to_string());
63        allowed_commands.insert("find".to_string());
64        allowed_commands.insert("mkdir".to_string());
65        allowed_commands.insert("touch".to_string());
66        allowed_commands.insert("cp".to_string());
67        allowed_commands.insert("mv".to_string());
68
69        // Development tools
70        allowed_commands.insert("git".to_string());
71        allowed_commands.insert("cargo".to_string());
72        allowed_commands.insert("rustc".to_string());
73        allowed_commands.insert("rustfmt".to_string());
74        allowed_commands.insert("clippy".to_string());
75        allowed_commands.insert("npm".to_string());
76        allowed_commands.insert("yarn".to_string());
77        allowed_commands.insert("node".to_string());
78        allowed_commands.insert("python".to_string());
79        allowed_commands.insert("python3".to_string());
80        allowed_commands.insert("pip".to_string());
81        allowed_commands.insert("pip3".to_string());
82        allowed_commands.insert("java".to_string());
83        allowed_commands.insert("javac".to_string());
84        allowed_commands.insert("maven".to_string());
85        allowed_commands.insert("gradle".to_string());
86        allowed_commands.insert("go".to_string());
87        allowed_commands.insert("dotnet".to_string());
88
89        // Compression tools
90        allowed_commands.insert("tar".to_string());
91        allowed_commands.insert("gzip".to_string());
92        allowed_commands.insert("gunzip".to_string());
93        allowed_commands.insert("zip".to_string());
94        allowed_commands.insert("unzip".to_string());
95
96        let mut blocked_commands = HashSet::new();
97
98        // Dangerous system commands
99        blocked_commands.insert("rm".to_string());
100        blocked_commands.insert("rmdir".to_string());
101        blocked_commands.insert("dd".to_string());
102        blocked_commands.insert("mkfs".to_string());
103        blocked_commands.insert("fdisk".to_string());
104        blocked_commands.insert("mount".to_string());
105        blocked_commands.insert("umount".to_string());
106        blocked_commands.insert("sudo".to_string());
107        blocked_commands.insert("su".to_string());
108        blocked_commands.insert("passwd".to_string());
109        blocked_commands.insert("chown".to_string());
110        blocked_commands.insert("chmod".to_string());
111        blocked_commands.insert("chgrp".to_string());
112        blocked_commands.insert("chroot".to_string());
113
114        // Network and system tools
115        blocked_commands.insert("nc".to_string());
116        blocked_commands.insert("netcat".to_string());
117        blocked_commands.insert("wget".to_string());
118        blocked_commands.insert("curl".to_string());
119        blocked_commands.insert("ssh".to_string());
120        blocked_commands.insert("scp".to_string());
121        blocked_commands.insert("rsync".to_string());
122
123        // Process control
124        blocked_commands.insert("kill".to_string());
125        blocked_commands.insert("killall".to_string());
126        blocked_commands.insert("pkill".to_string());
127        blocked_commands.insert("nohup".to_string());
128        blocked_commands.insert("screen".to_string());
129        blocked_commands.insert("tmux".to_string());
130
131        // System modification
132        blocked_commands.insert("systemctl".to_string());
133        blocked_commands.insert("service".to_string());
134        blocked_commands.insert("crontab".to_string());
135        blocked_commands.insert("at".to_string());
136        blocked_commands.insert("reboot".to_string());
137        blocked_commands.insert("shutdown".to_string());
138        blocked_commands.insert("halt".to_string());
139        blocked_commands.insert("poweroff".to_string());
140
141        Self {
142            max_execution_time: Duration::from_secs(300), // 5 minutes
143            max_memory_mb: 512,
144            max_cpu_percent: 80,
145            allowed_commands,
146            blocked_commands,
147            allow_network: false,
148            max_processes: 10,
149            strict_mode: true,
150        }
151    }
152}
153
154/// Sandbox error types
155#[derive(Debug, thiserror::Error)]
156pub enum SandboxError {
157    #[error("Command blocked by security policy: {command}")]
158    BlockedCommand { command: String },
159
160    #[error("Dangerous command pattern detected: {pattern}")]
161    DangerousPattern { pattern: String },
162
163    #[error("Path access denied: {path}")]
164    PathAccessDenied { path: String },
165
166    #[error("Resource limit exceeded: {resource}")]
167    ResourceLimitExceeded { resource: String },
168
169    #[error("Execution timeout after {seconds} seconds")]
170    ExecutionTimeout { seconds: u64 },
171
172    #[error("Sandbox setup failed: {reason}")]
173    SandboxSetupError { reason: String },
174
175    #[error("Command execution failed: {reason}")]
176    ExecutionError { reason: String },
177}
178
179/// Secure sandbox for executing commands in emulation mode
180pub struct Sandbox {
181    config: SandboxConfig,
182    dangerous_patterns: Vec<Regex>,
183}
184
185impl Sandbox {
186    /// Create a new sandbox with the given configuration
187    pub fn new(config: SandboxConfig) -> Result<Self, SandboxError> {
188        let dangerous_patterns = Self::compile_dangerous_patterns();
189
190        wrkflw_logging::info("Created new sandbox");
191
192        Ok(Self {
193            config,
194            dangerous_patterns,
195        })
196    }
197
198    /// Execute a command in the sandbox.
199    ///
200    /// The command runs **in-place** in `working_dir` (no file copying). The
201    /// caller is responsible for ensuring `working_dir` is the correct host
202    /// workspace — `SecureEmulationRuntime::run_container` rebases container
203    /// paths via the volume mount before calling in, matching the mount
204    /// semantics of docker/podman (#88).
205    ///
206    /// Security is enforced by `validate_command` (command whitelist / blocked
207    /// commands / dangerous patterns), `is_env_var_safe` (env var filtering),
208    /// and `execute_with_limits` (timeout). The previous copy-files-to-a-
209    /// private-workspace layer was not actually providing isolation — it was
210    /// breaking the run-step / artifact-handler workspace invariant.
211    pub async fn execute_command(
212        &self,
213        command: &[&str],
214        env_vars: &[(&str, &str)],
215        working_dir: &Path,
216    ) -> Result<crate::container::ContainerOutput, SandboxError> {
217        if command.is_empty() {
218            return Err(SandboxError::ExecutionError {
219                reason: "Empty command".to_string(),
220            });
221        }
222
223        let command_str = command.join(" ");
224
225        // Step 1: Validate command
226        self.validate_command(&command_str)?;
227
228        // Step 2: Execute in-place with limits
229        self.execute_with_limits(command, env_vars, working_dir)
230            .await
231    }
232
233    /// Validate that a command is safe to execute
234    fn validate_command(&self, command_str: &str) -> Result<(), SandboxError> {
235        // Check for dangerous patterns first
236        for pattern in &self.dangerous_patterns {
237            if pattern.is_match(command_str) {
238                wrkflw_logging::warning(&format!(
239                    "{} Blocked dangerous command pattern: {}",
240                    wrkflw_logging::symbols::BLOCKED,
241                    command_str
242                ));
243                return Err(SandboxError::DangerousPattern {
244                    pattern: command_str.to_string(),
245                });
246            }
247        }
248
249        // Split command by shell operators to validate each part
250        let command_parts = self.split_shell_command(command_str);
251
252        for part in command_parts {
253            let part = part.trim();
254            if part.is_empty() {
255                continue;
256            }
257
258            // Extract the base command from this part
259            let base_command = part.split_whitespace().next().unwrap_or("");
260            let command_name = Path::new(base_command)
261                .file_name()
262                .and_then(|s| s.to_str())
263                .unwrap_or(base_command);
264
265            // Skip shell built-ins and operators
266            if self.is_shell_builtin(command_name) {
267                continue;
268            }
269
270            // Check blocked commands
271            if self.config.blocked_commands.contains(command_name) {
272                wrkflw_logging::warning(&format!(
273                    "{} Blocked command: {}",
274                    wrkflw_logging::symbols::BLOCKED,
275                    command_name
276                ));
277                return Err(SandboxError::BlockedCommand {
278                    command: command_name.to_string(),
279                });
280            }
281
282            // In strict mode, only allow whitelisted commands
283            if self.config.strict_mode && !self.config.allowed_commands.contains(command_name) {
284                wrkflw_logging::warning(&format!(
285                    "{} Command not in whitelist (strict mode): {}",
286                    wrkflw_logging::symbols::BLOCKED,
287                    command_name
288                ));
289                return Err(SandboxError::BlockedCommand {
290                    command: command_name.to_string(),
291                });
292            }
293        }
294
295        wrkflw_logging::info(&format!(
296            "{} Command validation passed: {}",
297            wrkflw_logging::symbols::SUCCESS,
298            command_str
299        ));
300        Ok(())
301    }
302
303    /// Split shell command by operators while preserving quoted strings
304    fn split_shell_command(&self, command_str: &str) -> Vec<String> {
305        // Simple split by common shell operators
306        // This is not a full shell parser but handles most cases
307        let separators = ["&&", "||", ";", "|"];
308        let mut parts = vec![command_str.to_string()];
309
310        for separator in separators {
311            let mut new_parts = Vec::new();
312            for part in parts {
313                let split_parts: Vec<String> = part
314                    .split(separator)
315                    .map(|s| s.trim().to_string())
316                    .filter(|s| !s.is_empty())
317                    .collect();
318                new_parts.extend(split_parts);
319            }
320            parts = new_parts;
321        }
322
323        parts
324    }
325
326    /// Check if a command is a shell built-in
327    fn is_shell_builtin(&self, command: &str) -> bool {
328        let builtins = [
329            "true", "false", "test", "[", "echo", "printf", "cd", "pwd", "export", "set", "unset",
330            "alias", "history", "jobs", "fg", "bg", "wait", "read",
331        ];
332        builtins.contains(&command)
333    }
334
335    /// Execute command with resource limits and monitoring
336    async fn execute_with_limits(
337        &self,
338        command: &[&str],
339        env_vars: &[(&str, &str)],
340        working_dir: &Path,
341    ) -> Result<crate::container::ContainerOutput, SandboxError> {
342        // Join command parts and execute via shell for proper handling of operators
343        let command_str = command.join(" ");
344
345        let mut cmd = Command::new("sh");
346        cmd.arg("-c");
347        cmd.arg(&command_str);
348        cmd.current_dir(working_dir);
349        cmd.stdout(Stdio::piped());
350        cmd.stderr(Stdio::piped());
351
352        // Set environment variables (filtered)
353        for (key, value) in env_vars {
354            if self.is_env_var_safe(key) {
355                cmd.env(key, value);
356            }
357        }
358
359        // Add sandbox-specific environment variables
360        cmd.env("WRKFLW_SANDBOXED", "true");
361        cmd.env("WRKFLW_SANDBOX_MODE", "strict");
362
363        // Execute with timeout
364        let timeout_duration = self.config.max_execution_time;
365
366        wrkflw_logging::info(&format!(
367            "🏃 Executing sandboxed command: {} (timeout: {}s)",
368            command.join(" "),
369            timeout_duration.as_secs()
370        ));
371
372        let start_time = std::time::Instant::now();
373
374        let result = tokio::time::timeout(timeout_duration, async {
375            let output = cmd.output().map_err(|e| SandboxError::ExecutionError {
376                reason: format!("Command execution failed: {}", e),
377            })?;
378
379            Ok(crate::container::ContainerOutput {
380                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
381                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
382                exit_code: output.status.code().unwrap_or(-1),
383            })
384        })
385        .await;
386
387        let execution_time = start_time.elapsed();
388
389        match result {
390            Ok(output_result) => {
391                wrkflw_logging::info(&format!(
392                    "{} Sandboxed command completed in {:.2}s",
393                    wrkflw_logging::symbols::SUCCESS,
394                    execution_time.as_secs_f64()
395                ));
396                output_result
397            }
398            Err(_) => {
399                wrkflw_logging::warning(&format!(
400                    "{} Sandboxed command timed out after {:.2}s",
401                    wrkflw_logging::symbols::WARNING,
402                    timeout_duration.as_secs_f64()
403                ));
404                Err(SandboxError::ExecutionTimeout {
405                    seconds: timeout_duration.as_secs(),
406                })
407            }
408        }
409    }
410
411    /// Check if an environment variable is safe to pass through
412    fn is_env_var_safe(&self, key: &str) -> bool {
413        // Block dangerous environment variables
414        let dangerous_env_vars = [
415            "LD_PRELOAD",
416            "LD_LIBRARY_PATH",
417            "DYLD_INSERT_LIBRARIES",
418            "DYLD_LIBRARY_PATH",
419            "PATH",
420            "HOME",
421            "SHELL",
422        ];
423
424        !dangerous_env_vars.contains(&key)
425    }
426
427    /// Compile regex patterns for dangerous command detection
428    fn compile_dangerous_patterns() -> Vec<Regex> {
429        let patterns = [
430            r"rm\s+.*-rf?\s*/",       // rm -rf /
431            r"dd\s+.*of=/dev/",       // dd ... of=/dev/...
432            r">\s*/dev/sd[a-z]",      // > /dev/sda
433            r"mkfs\.",                // mkfs.ext4, etc.
434            r"fdisk\s+/dev/",         // fdisk /dev/...
435            r"mount\s+.*\s+/",        // mount ... /
436            r"chroot\s+/",            // chroot /
437            r"sudo\s+",               // sudo commands
438            r"su\s+",                 // su commands
439            r"bash\s+-c\s+.*rm.*-rf", // bash -c "rm -rf ..."
440            r"sh\s+-c\s+.*rm.*-rf",   // sh -c "rm -rf ..."
441            r"eval\s+.*rm.*-rf",      // eval "rm -rf ..."
442            r":\(\)\{.*;\};:",        // Fork bomb
443            r"/proc/sys/",            // /proc/sys access
444            r"/etc/passwd",           // /etc/passwd access
445            r"/etc/shadow",           // /etc/shadow access
446            r"nc\s+.*-e",             // netcat with exec
447            r"wget\s+.*\|\s*sh",      // wget ... | sh
448            r"curl\s+.*\|\s*sh",      // curl ... | sh
449        ];
450
451        patterns
452            .iter()
453            .filter_map(|pattern| {
454                Regex::new(pattern)
455                    .map_err(|e| {
456                        wrkflw_logging::warning(&format!(
457                            "Invalid regex pattern {}: {}",
458                            pattern, e
459                        ));
460                        e
461                    })
462                    .ok()
463            })
464            .collect()
465    }
466}
467
468/// Create a default sandbox configuration for CI/CD workflows
469pub fn create_workflow_sandbox_config() -> SandboxConfig {
470    SandboxConfig {
471        max_execution_time: Duration::from_secs(1800), // 30 minutes
472        max_memory_mb: 2048,                           // 2GB
473        max_processes: 50,
474        allow_network: true,
475        strict_mode: false,
476        ..Default::default()
477    }
478}
479
480/// Create a strict sandbox configuration for untrusted code
481pub fn create_strict_sandbox_config() -> SandboxConfig {
482    // Very limited command set
483    let allowed_commands = ["echo", "cat", "ls", "pwd", "date"]
484        .iter()
485        .map(|s| s.to_string())
486        .collect();
487
488    SandboxConfig {
489        max_execution_time: Duration::from_secs(60), // 1 minute
490        max_memory_mb: 128,                          // 128MB
491        max_processes: 5,
492        allow_network: false,
493        strict_mode: true,
494        allowed_commands,
495        ..Default::default()
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_dangerous_pattern_detection() {
505        let sandbox = Sandbox::new(SandboxConfig::default()).unwrap();
506
507        // Should block dangerous commands
508        assert!(sandbox.validate_command("rm -rf /").is_err());
509        assert!(sandbox
510            .validate_command("dd if=/dev/zero of=/dev/sda")
511            .is_err());
512        assert!(sandbox.validate_command("sudo rm -rf /home").is_err());
513        assert!(sandbox.validate_command("bash -c 'rm -rf /'").is_err());
514
515        // Should allow safe commands
516        assert!(sandbox.validate_command("echo hello").is_ok());
517        assert!(sandbox.validate_command("ls -la").is_ok());
518        assert!(sandbox.validate_command("cargo build").is_ok());
519    }
520
521    #[test]
522    fn test_command_whitelist() {
523        let config = create_strict_sandbox_config();
524        let sandbox = Sandbox::new(config).unwrap();
525
526        // Should allow whitelisted commands
527        assert!(sandbox.validate_command("echo hello").is_ok());
528        assert!(sandbox.validate_command("ls").is_ok());
529
530        // Should block non-whitelisted commands
531        assert!(sandbox.validate_command("git clone").is_err());
532        assert!(sandbox.validate_command("cargo build").is_err());
533    }
534}