Files
wrkflw/crates/ui/src/handlers/workflow.rs
bahdotsh 460357d9fe feat: Add comprehensive sandboxing for secure emulation mode
Security Features:
- Implement secure emulation runtime with command sandboxing
- Add command validation, filtering, and dangerous pattern detection
- Block harmful commands like 'rm -rf /', 'sudo', 'dd', etc.
- Add resource limits (CPU, memory, execution time, process count)
- Implement filesystem isolation and access controls
- Add environment variable sanitization
- Support shell operators (&&, ||, |, ;) with proper parsing

New Runtime Mode:
- Add 'secure-emulation' runtime option to CLI
- Update UI to support new runtime mode with green security indicator
- Mark legacy 'emulation' mode as unsafe in help text
- Default to secure mode for local development safety

Documentation:
- Create comprehensive security documentation (README_SECURITY.md)
- Update main README with security mode information
- Add example workflows demonstrating safe vs dangerous commands
- Include migration guide and best practices

Testing:
- Add comprehensive test suite for sandbox functionality
- Include security demo workflows for testing
- Test dangerous command blocking and safe command execution
- Verify resource limits and timeout functionality

Code Quality:
- Fix all clippy warnings with proper struct initialization
- Add proper error handling and user-friendly security messages
- Implement comprehensive logging for security events
- Follow Rust best practices throughout

This addresses security concerns by preventing accidental harmful
commands while maintaining full compatibility with legitimate CI/CD
workflows. Users can now safely run untrusted workflows locally
without risk to their host system.
2025-08-13 14:30:51 +05:30

568 lines
22 KiB
Rust

// Workflow handlers
use crate::app::App;
use crate::models::{ExecutionResultMsg, WorkflowExecution, WorkflowStatus};
use chrono::Local;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use wrkflw_evaluator::evaluate_workflow_file;
use wrkflw_executor::{self, JobStatus, RuntimeType, StepStatus};
// Validate a workflow or directory containing workflows
pub fn validate_workflow(path: &Path, verbose: bool) -> io::Result<()> {
let mut workflows = Vec::new();
if path.is_dir() {
let entries = std::fs::read_dir(path)?;
for entry in entries {
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_file() && wrkflw_utils::is_workflow_file(&entry_path) {
workflows.push(entry_path);
}
}
} else if path.is_file() {
workflows.push(PathBuf::from(path));
} else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Path does not exist: {}", path.display()),
));
}
let mut valid_count = 0;
let mut invalid_count = 0;
println!("Validating {} workflow file(s)...", workflows.len());
for workflow_path in workflows {
match evaluate_workflow_file(&workflow_path, verbose) {
Ok(result) => {
if result.is_valid {
println!("✅ Valid: {}", workflow_path.display());
valid_count += 1;
} else {
println!("❌ Invalid: {}", workflow_path.display());
for (i, issue) in result.issues.iter().enumerate() {
println!(" {}. {}", i + 1, issue);
}
invalid_count += 1;
}
}
Err(e) => {
println!("❌ Error processing {}: {}", workflow_path.display(), e);
invalid_count += 1;
}
}
}
println!(
"\nSummary: {} valid, {} invalid",
valid_count, invalid_count
);
Ok(())
}
// Execute a workflow through the CLI
pub async fn execute_workflow_cli(
path: &Path,
runtime_type: RuntimeType,
verbose: bool,
) -> io::Result<()> {
if !path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Workflow file does not exist: {}", path.display()),
));
}
println!("Validating workflow...");
match evaluate_workflow_file(path, false) {
Ok(result) => {
if !result.is_valid {
println!("❌ Cannot execute invalid workflow: {}", path.display());
for (i, issue) in result.issues.iter().enumerate() {
println!(" {}. {}", i + 1, issue);
}
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Workflow validation failed",
));
}
}
Err(e) => {
return Err(io::Error::other(format!(
"Error validating workflow: {}",
e
)));
}
}
// Check container runtime availability if container runtime is selected
let runtime_type = match runtime_type {
RuntimeType::Docker => {
if !wrkflw_executor::docker::is_available() {
println!("⚠️ Docker is not available. Using emulation mode instead.");
wrkflw_logging::warning("Docker is not available. Using emulation mode instead.");
RuntimeType::Emulation
} else {
RuntimeType::Docker
}
}
RuntimeType::Podman => {
if !wrkflw_executor::podman::is_available() {
println!("⚠️ Podman is not available. Using emulation mode instead.");
wrkflw_logging::warning("Podman is not available. Using emulation mode instead.");
RuntimeType::Emulation
} else {
RuntimeType::Podman
}
}
RuntimeType::SecureEmulation => RuntimeType::SecureEmulation,
RuntimeType::Emulation => RuntimeType::Emulation,
};
println!("Executing workflow: {}", path.display());
println!("Runtime mode: {:?}", runtime_type);
// Log the start of the execution in debug mode with more details
wrkflw_logging::debug(&format!(
"Starting workflow execution: path={}, runtime={:?}, verbose={}",
path.display(),
runtime_type,
verbose
));
let config = wrkflw_executor::ExecutionConfig {
runtime_type,
verbose,
preserve_containers_on_failure: false, // Default for this path
};
match wrkflw_executor::execute_workflow(path, config).await {
Ok(result) => {
println!("\nWorkflow execution results:");
// Track if the workflow had any failures
let mut any_job_failed = false;
for job in &result.jobs {
match job.status {
JobStatus::Success => {
println!("\n✅ Job succeeded: {}", job.name);
}
JobStatus::Failure => {
println!("\n❌ Job failed: {}", job.name);
any_job_failed = true;
}
JobStatus::Skipped => {
println!("\n⏭️ Job skipped: {}", job.name);
}
}
println!("-------------------------");
// Log the job details for debug purposes
wrkflw_logging::debug(&format!("Job: {}, Status: {:?}", job.name, job.status));
for step in job.steps.iter() {
match step.status {
StepStatus::Success => {
println!("{}", step.name);
// Check if this is a GitHub action output that should be hidden
let should_hide = std::env::var("WRKFLW_HIDE_ACTION_MESSAGES")
.map(|val| val == "true")
.unwrap_or(false)
&& step.output.contains("Would execute GitHub action:");
// Only show output if not hidden and it's short
if !should_hide
&& !step.output.trim().is_empty()
&& step.output.lines().count() <= 3
{
// For short outputs, show directly
println!(" {}", step.output.trim());
}
}
StepStatus::Failure => {
println!("{}", step.name);
// Ensure we capture and show exit code
if let Some(exit_code) = step
.output
.lines()
.find(|line| line.trim().starts_with("Exit code:"))
.map(|line| line.trim().to_string())
{
println!(" {}", exit_code);
}
// Show command/run details in debug mode
if wrkflw_logging::get_log_level() <= wrkflw_logging::LogLevel::Debug {
if let Some(cmd_output) = step
.output
.lines()
.skip_while(|l| !l.trim().starts_with("$"))
.take(1)
.next()
{
println!(" Command: {}", cmd_output.trim());
}
}
// Always show error output from failed steps, but keep it to a reasonable length
let output_lines: Vec<&str> = step
.output
.lines()
.filter(|line| !line.trim().starts_with("Exit code:"))
.collect();
if !output_lines.is_empty() {
println!(" Error output:");
for line in output_lines.iter().take(10) {
println!(" {}", line.trim().replace('\n', "\n "));
}
if output_lines.len() > 10 {
println!(
" ... (and {} more lines)",
output_lines.len() - 10
);
println!(" Use --debug to see full output");
}
}
}
StepStatus::Skipped => {
println!(" ⏭️ {} (skipped)", step.name);
}
}
// Always log the step details for debug purposes
wrkflw_logging::debug(&format!(
"Step: {}, Status: {:?}, Output length: {} lines",
step.name,
step.status,
step.output.lines().count()
));
// In debug mode, log all step output
if wrkflw_logging::get_log_level() == wrkflw_logging::LogLevel::Debug
&& !step.output.trim().is_empty()
{
wrkflw_logging::debug(&format!(
"Step output for '{}': \n{}",
step.name, step.output
));
}
}
}
if any_job_failed {
println!("\n❌ Workflow completed with failures");
// In the case of failure, we'll also inform the user about the debug option
// if they're not already using it
if wrkflw_logging::get_log_level() > wrkflw_logging::LogLevel::Debug {
println!(" Run with --debug for more detailed output");
}
} else {
println!("\n✅ Workflow completed successfully!");
}
Ok(())
}
Err(e) => {
println!("❌ Failed to execute workflow: {}", e);
wrkflw_logging::error(&format!("Failed to execute workflow: {}", e));
Err(io::Error::other(e))
}
}
}
// Helper function to execute workflow trigger using curl
pub async fn execute_curl_trigger(
workflow_name: &str,
branch: Option<&str>,
) -> Result<(Vec<wrkflw_executor::JobResult>, ()), String> {
// Get GitHub token
let token = std::env::var("GITHUB_TOKEN").map_err(|_| {
"GitHub token not found. Please set GITHUB_TOKEN environment variable".to_string()
})?;
// Debug log to check if GITHUB_TOKEN is set
match std::env::var("GITHUB_TOKEN") {
Ok(token) => wrkflw_logging::info(&format!("GITHUB_TOKEN is set: {}", &token[..5])), // Log first 5 characters for security
Err(_) => wrkflw_logging::error("GITHUB_TOKEN is not set"),
}
// Get repository information
let repo_info = wrkflw_github::get_repo_info()
.map_err(|e| format!("Failed to get repository info: {}", e))?;
// Determine branch to use
let branch_ref = branch.unwrap_or(&repo_info.default_branch);
// Extract just the workflow name from the path if it's a full path
let workflow_name = if workflow_name.contains('/') {
Path::new(workflow_name)
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| "Invalid workflow name".to_string())?
} else {
workflow_name
};
wrkflw_logging::info(&format!("Using workflow name: {}", workflow_name));
// Construct JSON payload
let payload = serde_json::json!({
"ref": branch_ref
});
// Construct API URL
let url = format!(
"https://api.github.com/repos/{}/{}/actions/workflows/{}.yml/dispatches",
repo_info.owner, repo_info.repo, workflow_name
);
wrkflw_logging::info(&format!("Triggering workflow at URL: {}", url));
// Create a reqwest client
let client = reqwest::Client::new();
// Send the request using reqwest
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", token.trim()))
.header("Accept", "application/vnd.github.v3+json")
.header("Content-Type", "application/json")
.header("User-Agent", "wrkflw-cli")
.json(&payload)
.send()
.await
.map_err(|e| format!("Failed to send request: {}", e))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let error_message = response
.text()
.await
.unwrap_or_else(|_| format!("Unknown error (HTTP {})", status));
return Err(format!("API error: {} - {}", status, error_message));
}
// Success message with URL to view the workflow
let success_msg = format!(
"Workflow triggered successfully. View it at: https://github.com/{}/{}/actions/workflows/{}.yml",
repo_info.owner, repo_info.repo, workflow_name
);
// Create a job result structure
let job_result = wrkflw_executor::JobResult {
name: "GitHub Trigger".to_string(),
status: wrkflw_executor::JobStatus::Success,
steps: vec![wrkflw_executor::StepResult {
name: "Remote Trigger".to_string(),
status: wrkflw_executor::StepStatus::Success,
output: success_msg,
}],
logs: "Workflow triggered remotely on GitHub".to_string(),
};
Ok((vec![job_result], ()))
}
// Extract common workflow execution logic to avoid duplication
pub fn start_next_workflow_execution(
app: &mut App,
tx_clone: &mpsc::Sender<ExecutionResultMsg>,
verbose: bool,
) {
if let Some(next_idx) = app.get_next_workflow_to_execute() {
app.current_execution = Some(next_idx);
let tx_clone_inner = tx_clone.clone();
let workflow_path = app.workflows[next_idx].path.clone();
// Log whether verbose mode is enabled
if verbose {
app.logs
.push("Verbose mode: Step outputs will be displayed in full".to_string());
wrkflw_logging::info("Verbose mode: Step outputs will be displayed in full");
} else {
app.logs.push(
"Standard mode: Only step status will be shown (use --verbose for full output)"
.to_string(),
);
wrkflw_logging::info(
"Standard mode: Only step status will be shown (use --verbose for full output)",
);
}
// Check container runtime availability again if container runtime is selected
let runtime_type = match app.runtime_type {
RuntimeType::Docker => {
// Use safe FD redirection to check Docker availability
let is_docker_available = match wrkflw_utils::fd::with_stderr_to_null(
wrkflw_executor::docker::is_available,
) {
Ok(result) => result,
Err(_) => {
wrkflw_logging::debug(
"Failed to redirect stderr when checking Docker availability.",
);
false
}
};
if !is_docker_available {
app.logs
.push("Docker is not available. Using emulation mode instead.".to_string());
wrkflw_logging::warning(
"Docker is not available. Using emulation mode instead.",
);
RuntimeType::Emulation
} else {
RuntimeType::Docker
}
}
RuntimeType::Podman => {
// Use safe FD redirection to check Podman availability
let is_podman_available = match wrkflw_utils::fd::with_stderr_to_null(
wrkflw_executor::podman::is_available,
) {
Ok(result) => result,
Err(_) => {
wrkflw_logging::debug(
"Failed to redirect stderr when checking Podman availability.",
);
false
}
};
if !is_podman_available {
app.logs
.push("Podman is not available. Using emulation mode instead.".to_string());
wrkflw_logging::warning(
"Podman is not available. Using emulation mode instead.",
);
RuntimeType::Emulation
} else {
RuntimeType::Podman
}
}
RuntimeType::SecureEmulation => RuntimeType::SecureEmulation,
RuntimeType::Emulation => RuntimeType::Emulation,
};
let validation_mode = app.validation_mode;
let preserve_containers_on_failure = app.preserve_containers_on_failure;
// Update workflow status and add execution details
app.workflows[next_idx].status = WorkflowStatus::Running;
// Initialize execution details if not already done
if app.workflows[next_idx].execution_details.is_none() {
app.workflows[next_idx].execution_details = Some(WorkflowExecution {
jobs: Vec::new(),
start_time: Local::now(),
end_time: None,
logs: Vec::new(),
progress: 0.0,
});
}
thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
Ok(runtime) => runtime,
Err(e) => {
let _ = tx_clone_inner.send((
next_idx,
Err(format!("Failed to create Tokio runtime: {}", e)),
));
return;
}
};
let result = rt.block_on(async {
if validation_mode {
// Perform validation instead of execution
match evaluate_workflow_file(&workflow_path, verbose) {
Ok(validation_result) => {
// Create execution result based on validation
let status = if validation_result.is_valid {
wrkflw_executor::JobStatus::Success
} else {
wrkflw_executor::JobStatus::Failure
};
// Create a synthetic job result for validation
let jobs = vec![wrkflw_executor::JobResult {
name: "Validation".to_string(),
status,
steps: vec![wrkflw_executor::StepResult {
name: "Validator".to_string(),
status: if validation_result.is_valid {
wrkflw_executor::StepStatus::Success
} else {
wrkflw_executor::StepStatus::Failure
},
output: validation_result.issues.join("\n"),
}],
logs: format!(
"Validation result: {}",
if validation_result.is_valid {
"PASSED"
} else {
"FAILED"
}
),
}];
Ok((jobs, ()))
}
Err(e) => Err(e.to_string()),
}
} else {
// Use safe FD redirection for execution
let config = wrkflw_executor::ExecutionConfig {
runtime_type,
verbose,
preserve_containers_on_failure,
};
let execution_result = wrkflw_utils::fd::with_stderr_to_null(|| {
futures::executor::block_on(async {
wrkflw_executor::execute_workflow(&workflow_path, config).await
})
})
.map_err(|e| format!("Failed to redirect stderr during execution: {}", e))?;
match execution_result {
Ok(execution_result) => {
// Send back the job results in a wrapped result
Ok((execution_result.jobs, ()))
}
Err(e) => Err(e.to_string()),
}
}
});
// Only send if we get a valid result
if let Err(e) = tx_clone_inner.send((next_idx, result)) {
wrkflw_logging::error(&format!("Error sending execution result: {}", e));
}
});
} else {
app.running = false;
let timestamp = Local::now().format("%H:%M:%S").to_string();
app.logs
.push(format!("[{}] All workflows completed execution", timestamp));
wrkflw_logging::info("All workflows completed execution");
}
}