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#[derive(Debug, Clone)]
17pub struct SandboxConfig {
18 pub max_execution_time: Duration,
20 pub max_memory_mb: u64,
22 pub max_cpu_percent: u64,
24 pub allowed_commands: HashSet<String>,
26 pub blocked_commands: HashSet<String>,
28 pub allow_network: bool,
30 pub max_processes: u32,
32 pub strict_mode: bool,
34}
35
36impl Default for SandboxConfig {
37 fn default() -> Self {
38 let mut allowed_commands = HashSet::new();
39
40 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 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 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 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 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 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 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 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), 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#[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
179pub struct Sandbox {
181 config: SandboxConfig,
182 dangerous_patterns: Vec<Regex>,
183}
184
185impl Sandbox {
186 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 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 self.validate_command(&command_str)?;
227
228 self.execute_with_limits(command, env_vars, working_dir)
230 .await
231 }
232
233 fn validate_command(&self, command_str: &str) -> Result<(), SandboxError> {
235 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 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 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 if self.is_shell_builtin(command_name) {
267 continue;
268 }
269
270 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 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 fn split_shell_command(&self, command_str: &str) -> Vec<String> {
305 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 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 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 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 for (key, value) in env_vars {
354 if self.is_env_var_safe(key) {
355 cmd.env(key, value);
356 }
357 }
358
359 cmd.env("WRKFLW_SANDBOXED", "true");
361 cmd.env("WRKFLW_SANDBOX_MODE", "strict");
362
363 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 fn is_env_var_safe(&self, key: &str) -> bool {
413 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 fn compile_dangerous_patterns() -> Vec<Regex> {
429 let patterns = [
430 r"rm\s+.*-rf?\s*/", r"dd\s+.*of=/dev/", r">\s*/dev/sd[a-z]", r"mkfs\.", r"fdisk\s+/dev/", r"mount\s+.*\s+/", r"chroot\s+/", r"sudo\s+", r"su\s+", r"bash\s+-c\s+.*rm.*-rf", r"sh\s+-c\s+.*rm.*-rf", r"eval\s+.*rm.*-rf", r":\(\)\{.*;\};:", r"/proc/sys/", r"/etc/passwd", r"/etc/shadow", r"nc\s+.*-e", r"wget\s+.*\|\s*sh", r"curl\s+.*\|\s*sh", ];
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
468pub fn create_workflow_sandbox_config() -> SandboxConfig {
470 SandboxConfig {
471 max_execution_time: Duration::from_secs(1800), max_memory_mb: 2048, max_processes: 50,
474 allow_network: true,
475 strict_mode: false,
476 ..Default::default()
477 }
478}
479
480pub fn create_strict_sandbox_config() -> SandboxConfig {
482 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), max_memory_mb: 128, 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 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 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 assert!(sandbox.validate_command("echo hello").is_ok());
528 assert!(sandbox.validate_command("ls").is_ok());
529
530 assert!(sandbox.validate_command("git clone").is_err());
532 assert!(sandbox.validate_command("cargo build").is_err());
533 }
534}