Skip to main content

wrkflw_runtime/
secure_emulation.rs

1use crate::container::{
2    rebase_working_dir_or_error, ContainerError, ContainerOutput, ContainerRuntime,
3};
4use crate::sandbox::{create_workflow_sandbox_config, Sandbox, SandboxConfig, SandboxError};
5use async_trait::async_trait;
6use std::path::Path;
7use wrkflw_logging;
8
9/// Secure emulation runtime that uses sandboxing for safety
10pub struct SecureEmulationRuntime {
11    sandbox: Sandbox,
12}
13
14impl Default for SecureEmulationRuntime {
15    fn default() -> Self {
16        Self::new()
17    }
18}
19
20impl SecureEmulationRuntime {
21    /// Create a new secure emulation runtime with default workflow-friendly configuration
22    pub fn new() -> Self {
23        let config = create_workflow_sandbox_config();
24        let sandbox = Sandbox::new(config).expect("Failed to create sandbox");
25
26        wrkflw_logging::info(&format!(
27            "{} Initialized secure emulation runtime with sandboxing",
28            wrkflw_logging::symbols::LOCK
29        ));
30
31        Self { sandbox }
32    }
33
34    /// Create a new secure emulation runtime with custom sandbox configuration
35    pub fn new_with_config(config: SandboxConfig) -> Result<Self, ContainerError> {
36        let sandbox = Sandbox::new(config).map_err(|e| {
37            ContainerError::ContainerStart(format!("Failed to create sandbox: {}", e))
38        })?;
39
40        wrkflw_logging::info(&format!(
41            "{} Initialized secure emulation runtime with custom config",
42            wrkflw_logging::symbols::LOCK
43        ));
44
45        Ok(Self { sandbox })
46    }
47}
48
49#[async_trait]
50impl ContainerRuntime for SecureEmulationRuntime {
51    async fn run_container(
52        &self,
53        image: &str,
54        command: &[&str],
55        env_vars: &[(&str, &str)],
56        working_dir: &Path,
57        volumes: &[(&Path, &Path)],
58        entrypoint: Option<&str>,
59    ) -> Result<ContainerOutput, ContainerError> {
60        if let Some(ep) = entrypoint {
61            wrkflw_logging::warning(&format!(
62                "Secure emulation mode ignoring entrypoint override '{}' for image '{}'. \
63                 Use --runtime docker for full Docker action support.",
64                ep, image
65            ));
66        }
67
68        wrkflw_logging::info(&format!(
69            "{} Executing sandboxed command: {} (image: {})",
70            wrkflw_logging::symbols::LOCK,
71            command.join(" "),
72            image
73        ));
74
75        // Rebase the container-visible working_dir onto its host-side volume
76        // source, matching EmulationRuntime and docker/podman (#88). Without
77        // this, `run:` steps and artifact/cache handlers observe different
78        // host directories.
79        let host_working_dir =
80            rebase_working_dir_or_error(working_dir, volumes, "secure_emulation")?;
81
82        // Use sandbox to execute the command safely
83        let result = self
84            .sandbox
85            .execute_command(command, env_vars, &host_working_dir)
86            .await;
87
88        match result {
89            Ok(output) => {
90                wrkflw_logging::info(&format!(
91                    "{} Sandboxed command completed successfully",
92                    wrkflw_logging::symbols::SUCCESS
93                ));
94                Ok(output)
95            }
96            Err(SandboxError::BlockedCommand { command }) => {
97                let error_msg = format!(
98                    "{} SECURITY BLOCK: Command '{}' is not allowed in secure emulation mode. \
99                     This command was blocked for security reasons. \
100                     If you need to run this command, please use Docker or Podman mode instead.",
101                    wrkflw_logging::symbols::BLOCKED,
102                    command
103                );
104                wrkflw_logging::warning(&error_msg);
105                Err(ContainerError::ContainerExecution(error_msg))
106            }
107            Err(SandboxError::DangerousPattern { pattern }) => {
108                let error_msg = format!(
109                    "{} SECURITY BLOCK: Dangerous command pattern detected: '{}'. \
110                     This command was blocked because it matches a known dangerous pattern. \
111                     Please review your workflow for potentially harmful commands.",
112                    wrkflw_logging::symbols::BLOCKED,
113                    pattern
114                );
115                wrkflw_logging::warning(&error_msg);
116                Err(ContainerError::ContainerExecution(error_msg))
117            }
118            Err(SandboxError::ExecutionTimeout { seconds }) => {
119                let error_msg = format!(
120                    "{} Command execution timed out after {} seconds. \
121                     Consider optimizing your command or increasing timeout limits.",
122                    wrkflw_logging::symbols::WARNING,
123                    seconds
124                );
125                wrkflw_logging::warning(&error_msg);
126                Err(ContainerError::ContainerExecution(error_msg))
127            }
128            Err(SandboxError::PathAccessDenied { path }) => {
129                let error_msg = format!(
130                    "{} Path access denied: '{}'. \
131                     The sandbox restricts file system access for security.",
132                    wrkflw_logging::symbols::BLOCKED,
133                    path
134                );
135                wrkflw_logging::warning(&error_msg);
136                Err(ContainerError::ContainerExecution(error_msg))
137            }
138            Err(SandboxError::ResourceLimitExceeded { resource }) => {
139                let error_msg = format!(
140                    "{} Resource limit exceeded: {}. \
141                     Your command used too many system resources.",
142                    wrkflw_logging::symbols::WARNING,
143                    resource
144                );
145                wrkflw_logging::warning(&error_msg);
146                Err(ContainerError::ContainerExecution(error_msg))
147            }
148            Err(e) => {
149                let error_msg = format!("Sandbox execution failed: {}", e);
150                wrkflw_logging::error(&error_msg);
151                Err(ContainerError::ContainerExecution(error_msg))
152            }
153        }
154    }
155
156    async fn pull_image(&self, image: &str) -> Result<(), ContainerError> {
157        wrkflw_logging::info(&format!(
158            "{} Secure emulation: Pretending to pull image {}",
159            wrkflw_logging::symbols::LOCK,
160            image
161        ));
162        Ok(())
163    }
164
165    async fn build_image(
166        &self,
167        dockerfile: &Path,
168        tag: &str,
169        _context_dir: &Path,
170    ) -> Result<(), ContainerError> {
171        wrkflw_logging::info(&format!(
172            "{} Secure emulation: Pretending to build image {} from {}",
173            wrkflw_logging::symbols::LOCK,
174            tag,
175            dockerfile.display()
176        ));
177        Ok(())
178    }
179
180    async fn image_exists(&self, _tag: &str) -> Result<bool, ContainerError> {
181        Ok(false)
182    }
183
184    async fn prepare_language_environment(
185        &self,
186        language: &str,
187        version: Option<&str>,
188        _additional_packages: Option<Vec<String>>,
189    ) -> Result<String, ContainerError> {
190        // For secure emulation runtime, we'll use a simplified approach
191        // that doesn't require building custom images
192        let base_image = match language {
193            "python" => version.map_or("python:3.11-slim".to_string(), |v| format!("python:{}", v)),
194            "node" => version.map_or("node:20-slim".to_string(), |v| format!("node:{}", v)),
195            "java" => version.map_or("eclipse-temurin:17-jdk".to_string(), |v| {
196                format!("eclipse-temurin:{}", v)
197            }),
198            "go" => version.map_or("golang:1.21-slim".to_string(), |v| format!("golang:{}", v)),
199            "dotnet" => version.map_or("mcr.microsoft.com/dotnet/sdk:7.0".to_string(), |v| {
200                format!("mcr.microsoft.com/dotnet/sdk:{}", v)
201            }),
202            "rust" => version.map_or("rust:latest".to_string(), |v| format!("rust:{}", v)),
203            _ => {
204                return Err(ContainerError::ContainerStart(format!(
205                    "Unsupported language: {}",
206                    language
207                )))
208            }
209        };
210
211        // For emulation, we'll just return the base image
212        // The actual package installation will be handled during container execution
213        Ok(base_image)
214    }
215}
216
217/// Handle special actions in secure emulation mode
218pub async fn handle_special_action_secure(action: &str) -> Result<(), ContainerError> {
219    // Extract owner, repo and version from the action
220    let action_parts: Vec<&str> = action.split('@').collect();
221    let action_name = action_parts[0];
222    let action_version = if action_parts.len() > 1 {
223        action_parts[1]
224    } else {
225        "latest"
226    };
227
228    wrkflw_logging::info(&format!(
229        "{} Processing action in secure mode: {} @ {}",
230        wrkflw_logging::symbols::LOCK,
231        action_name,
232        action_version
233    ));
234
235    // In secure mode, we're more restrictive about what actions we allow
236    match action_name {
237        // Core GitHub actions that are generally safe
238        name if name.starts_with("actions/checkout") => {
239            wrkflw_logging::info(&format!(
240                "{} Checkout action - workspace files are prepared securely",
241                wrkflw_logging::symbols::SUCCESS
242            ));
243        }
244        name if name.starts_with("actions/setup-node") => {
245            wrkflw_logging::info(&format!(
246                "{} Node.js setup - using system Node.js in secure mode",
247                wrkflw_logging::symbols::WARNING
248            ));
249            check_command_available_secure("node", "Node.js", "https://nodejs.org/");
250        }
251        name if name.starts_with("actions/setup-python") => {
252            wrkflw_logging::info(&format!(
253                "{} Python setup - using system Python in secure mode",
254                wrkflw_logging::symbols::WARNING
255            ));
256            check_command_available_secure("python", "Python", "https://www.python.org/downloads/");
257        }
258        name if name.starts_with("actions/setup-java") => {
259            wrkflw_logging::info(&format!(
260                "{} Java setup - using system Java in secure mode",
261                wrkflw_logging::symbols::WARNING
262            ));
263            check_command_available_secure("java", "Java", "https://adoptium.net/");
264        }
265        name if name.starts_with("actions/cache") => {
266            wrkflw_logging::info(&format!(
267                "{} Cache action - caching disabled in secure emulation mode",
268                wrkflw_logging::symbols::WARNING
269            ));
270        }
271
272        // Rust-specific actions
273        name if name.starts_with("actions-rs/cargo") => {
274            wrkflw_logging::info(&format!(
275                "{} Rust cargo action - using system Rust in secure mode",
276                wrkflw_logging::symbols::WARNING
277            ));
278            check_command_available_secure("cargo", "Rust/Cargo", "https://rustup.rs/");
279        }
280        name if name.starts_with("actions-rs/toolchain") => {
281            wrkflw_logging::info(&format!(
282                "{} Rust toolchain action - using system Rust in secure mode",
283                wrkflw_logging::symbols::WARNING
284            ));
285            check_command_available_secure("rustc", "Rust", "https://rustup.rs/");
286        }
287        name if name.starts_with("actions-rs/fmt") => {
288            wrkflw_logging::info(&format!(
289                "{} Rust formatter action - using system rustfmt in secure mode",
290                wrkflw_logging::symbols::WARNING
291            ));
292            check_command_available_secure("rustfmt", "rustfmt", "rustup component add rustfmt");
293        }
294
295        // Potentially dangerous actions that we warn about
296        name if name.contains("docker") || name.contains("container") => {
297            wrkflw_logging::warning(&format!(
298                "{} Docker/container action '{}' is not supported in secure emulation mode. \
299                 Use Docker or Podman mode for container actions.",
300                wrkflw_logging::symbols::BLOCKED,
301                action_name
302            ));
303        }
304        name if name.contains("ssh") || name.contains("deploy") => {
305            wrkflw_logging::warning(&format!(
306                "{} SSH/deployment action '{}' is restricted in secure emulation mode. \
307                 Use Docker or Podman mode for deployment actions.",
308                wrkflw_logging::symbols::BLOCKED,
309                action_name
310            ));
311        }
312
313        // Unknown actions
314        _ => {
315            wrkflw_logging::warning(&format!(
316                "{} Unknown action '{}' in secure emulation mode. \
317                 Some functionality may be limited or unavailable.",
318                wrkflw_logging::symbols::WARNING,
319                action_name
320            ));
321        }
322    }
323
324    Ok(())
325}
326
327/// Check if a command is available, with security-focused messaging
328fn check_command_available_secure(command: &str, name: &str, install_url: &str) {
329    use std::process::Command;
330
331    let is_available = Command::new("which")
332        .arg(command)
333        .output()
334        .map(|output| output.status.success())
335        .unwrap_or(false);
336
337    if !is_available {
338        wrkflw_logging::warning(&format!(
339            "🔧 {} is required but not found on the system",
340            name
341        ));
342        wrkflw_logging::info(&format!(
343            "To use this action in secure mode, please install {}: {}",
344            name, install_url
345        ));
346        wrkflw_logging::info(&format!(
347            "Alternatively, use Docker or Podman mode for automatic {} installation",
348            name
349        ));
350    } else {
351        // Try to get version information
352        if let Ok(output) = Command::new(command).arg("--version").output() {
353            if output.status.success() {
354                let version = String::from_utf8_lossy(&output.stdout);
355                wrkflw_logging::info(&format!(
356                    "{} Using system {} in secure mode: {}",
357                    wrkflw_logging::symbols::SUCCESS,
358                    name,
359                    version.trim()
360                ));
361            }
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::sandbox::create_strict_sandbox_config;
370    use std::path::PathBuf;
371
372    #[tokio::test]
373    async fn test_secure_emulation_blocks_dangerous_commands() {
374        let config = create_strict_sandbox_config();
375        let runtime = SecureEmulationRuntime::new_with_config(config).unwrap();
376
377        // Should block dangerous commands
378        let result = runtime
379            .run_container(
380                "alpine:latest",
381                &["rm", "-rf", "/"],
382                &[],
383                &PathBuf::from("."),
384                &[],
385                None,
386            )
387            .await;
388
389        assert!(result.is_err());
390        let error_msg = result.unwrap_err().to_string();
391        assert!(error_msg.contains("SECURITY BLOCK"));
392    }
393
394    #[tokio::test]
395    async fn test_secure_emulation_allows_safe_commands() {
396        let runtime = SecureEmulationRuntime::new();
397
398        // Should allow safe commands
399        let result = runtime
400            .run_container(
401                "alpine:latest",
402                &["echo", "hello world"],
403                &[],
404                &PathBuf::from("."),
405                &[],
406                None,
407            )
408            .await;
409
410        assert!(result.is_ok());
411        let output = result.unwrap();
412        assert!(output.stdout.contains("hello world"));
413        assert_eq!(output.exit_code, 0);
414    }
415
416    /// Regression for #88: a container-visible working dir must be rebased
417    /// through the `volumes` mapping onto its host counterpart, so commands
418    /// run in the caller's workspace rather than a hidden sandbox copy.
419    #[cfg(not(target_os = "windows"))]
420    #[tokio::test]
421    async fn secure_emulation_rebases_container_working_dir_via_volumes() {
422        let runtime = SecureEmulationRuntime::new();
423        let host_tempdir = tempfile::tempdir().unwrap();
424
425        let host = host_tempdir.path();
426        let container = Path::new("/github/workspace");
427
428        // Run `pwd` in the container workspace; with the rebase it should
429        // print the host tempdir.
430        let result = runtime
431            .run_container(
432                "alpine:latest",
433                &["pwd"],
434                &[],
435                container,
436                &[(host, container)],
437                None,
438            )
439            .await
440            .expect("secure_emulation run failed");
441        assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr);
442        // `pwd` may canonicalize /var → /private/var on macOS, so compare
443        // canonical forms.
444        let canon_host = host.canonicalize().unwrap();
445        let canon_pwd = PathBuf::from(result.stdout.trim()).canonicalize().unwrap();
446        assert_eq!(canon_pwd, canon_host);
447    }
448
449    #[tokio::test]
450    async fn secure_emulation_errors_when_volumes_dont_cover_working_dir() {
451        let runtime = SecureEmulationRuntime::new();
452
453        // /github/workspace doesn't exist on host and no volume covers it.
454        let result = runtime
455            .run_container(
456                "alpine:latest",
457                &["echo", "nope"],
458                &[],
459                Path::new("/github/workspace"),
460                &[],
461                None,
462            )
463            .await;
464
465        let err = result.expect_err("should error when no volume covers working_dir");
466        let msg = err.to_string();
467        assert!(
468            msg.contains("not covered by any volume mount"),
469            "unexpected error: {}",
470            msg
471        );
472    }
473}