mirror of
https://github.com/bahdotsh/wrkflw.git
synced 2025-12-16 11:47:45 +01:00
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.
568 lines
22 KiB
Rust
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");
|
|
}
|
|
}
|