//! Security Tests for Secret Handling //! //! These tests verify that secrets are NOT exposed in process environment //! or command-line arguments, ensuring secure secret passing via stdin. use attune_common::models::runtime::{InterpreterConfig, RuntimeExecutionConfig}; use attune_worker::runtime::process::ProcessRuntime; use attune_worker::runtime::shell::ShellRuntime; use attune_worker::runtime::{ExecutionContext, Runtime}; use std::collections::HashMap; use std::path::PathBuf; use tempfile::TempDir; fn make_python_process_runtime(packs_base_dir: PathBuf) -> ProcessRuntime { let config = RuntimeExecutionConfig { interpreter: InterpreterConfig { binary: "python3".to_string(), args: vec!["-u".to_string()], file_extension: Some(".py".to_string()), }, environment: None, dependencies: None, }; let runtime_envs_dir = packs_base_dir.parent().unwrap_or(&packs_base_dir).join("runtime_envs"); ProcessRuntime::new("python".to_string(), config, packs_base_dir, runtime_envs_dir) } #[tokio::test] async fn test_python_secrets_not_in_environ() { let tmp = TempDir::new().unwrap(); let runtime = make_python_process_runtime(tmp.path().to_path_buf()); // Inline Python code that checks environment for secrets let code = r#" import os, json environ_str = str(os.environ) # Secrets should NOT be in environment has_secret_in_env = 'super_secret_key_do_not_expose' in environ_str has_password_in_env = 'secret_pass_123' in environ_str has_secret_prefix = any(k.startswith('SECRET_') for k in os.environ) result = { 'secrets_in_environ': has_secret_in_env or has_password_in_env or has_secret_prefix, 'environ_check': 'SECRET_' not in environ_str } print(json.dumps(result)) "#; let context = ExecutionContext { execution_id: 1, action_ref: "security.test_environ".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: { let mut s = HashMap::new(); s.insert( "api_key".to_string(), "super_secret_key_do_not_expose".to_string(), ); s.insert("password".to_string(), "secret_pass_123".to_string()); s }, timeout: Some(10), working_dir: None, entry_point: "inline".to_string(), code: Some(code.to_string()), code_path: None, runtime_name: Some("python".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::Json, }; let result = runtime.execute(context).await.unwrap(); assert_eq!( result.exit_code, 0, "Execution should succeed. stderr: {}", result.stderr ); let result_data = result.result.expect("Should have parsed JSON result"); // Critical security check: secrets should NOT be in environment assert_eq!( result_data.get("secrets_in_environ").unwrap(), &serde_json::json!(false), "SECURITY FAILURE: Secrets found in process environment!" ); // Verify no SECRET_ prefix in environment assert_eq!( result_data.get("environ_check").unwrap(), &serde_json::json!(true), "Environment should not contain SECRET_ prefix variables" ); } #[tokio::test] async fn test_shell_secrets_not_in_environ() { let runtime = ShellRuntime::new(); let context = ExecutionContext { execution_id: 2, action_ref: "security.test_shell_environ".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: { let mut s = HashMap::new(); s.insert( "api_key".to_string(), "super_secret_key_do_not_expose".to_string(), ); s.insert("password".to_string(), "secret_pass_123".to_string()); s }, timeout: Some(10), working_dir: None, entry_point: "shell".to_string(), code: Some( r#" # Check if secrets are in environment variables if printenv | grep -q "super_secret_key_do_not_expose"; then echo "SECURITY_FAIL: Secret found in environment" exit 1 fi if printenv | grep -q "secret_pass_123"; then echo "SECURITY_FAIL: Password found in environment" exit 1 fi if printenv | grep -q "SECRET_API_KEY"; then echo "SECURITY_FAIL: SECRET_ prefix found in environment" exit 1 fi # But secrets SHOULD be accessible via get_secret function api_key=$(get_secret 'api_key') password=$(get_secret 'password') if [ "$api_key" != "super_secret_key_do_not_expose" ]; then echo "ERROR: Secret not accessible via get_secret" exit 1 fi if [ "$password" != "secret_pass_123" ]; then echo "ERROR: Password not accessible via get_secret" exit 1 fi echo "SECURITY_PASS: Secrets not in environment but accessible via get_secret" "# .to_string(), ), code_path: None, runtime_name: Some("shell".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::default(), }; let result = runtime.execute(context).await.unwrap(); // Check execution succeeded assert!( result.is_success(), "Execution should succeed. stderr: {}", result.stderr ); assert_eq!(result.exit_code, 0, "Exit code should be 0"); // Verify security pass message assert!( result.stdout.contains("SECURITY_PASS"), "Security checks should pass. stdout: {}", result.stdout ); assert!( !result.stdout.contains("SECURITY_FAIL"), "Should not have security failures. stdout: {}", result.stdout ); } #[tokio::test] async fn test_python_secrets_isolated_between_actions() { let tmp = TempDir::new().unwrap(); let runtime = make_python_process_runtime(tmp.path().to_path_buf()); // First action with secret A — read it from stdin let code1 = r#" import sys, json # Read secrets from stdin (the process executor writes them as JSON on stdin) secrets_line = sys.stdin.readline().strip() secrets = json.loads(secrets_line) if secrets_line else {} print(json.dumps({'secret_a': secrets.get('secret_a')})) "#; let context1 = ExecutionContext { execution_id: 3, action_ref: "security.action1".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: { let mut s = HashMap::new(); s.insert("secret_a".to_string(), "value_a".to_string()); s }, timeout: Some(10), working_dir: None, entry_point: "inline".to_string(), code: Some(code1.to_string()), code_path: None, runtime_name: Some("python".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::Json, }; let result1 = runtime.execute(context1).await.unwrap(); assert_eq!( result1.exit_code, 0, "First action should succeed. stderr: {}", result1.stderr ); // Second action with secret B — should NOT see secret A let code2 = r#" import sys, json secrets_line = sys.stdin.readline().strip() secrets = json.loads(secrets_line) if secrets_line else {} print(json.dumps({ 'secret_a_leaked': secrets.get('secret_a') is not None, 'secret_b_present': secrets.get('secret_b') == 'value_b' })) "#; let context2 = ExecutionContext { execution_id: 4, action_ref: "security.action2".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: { let mut s = HashMap::new(); s.insert("secret_b".to_string(), "value_b".to_string()); s }, timeout: Some(10), working_dir: None, entry_point: "inline".to_string(), code: Some(code2.to_string()), code_path: None, runtime_name: Some("python".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::Json, }; let result2 = runtime.execute(context2).await.unwrap(); assert_eq!( result2.exit_code, 0, "Second action should succeed. stderr: {}", result2.stderr ); let result_data = result2.result.expect("Should have parsed JSON result"); // Verify secrets don't leak between actions assert_eq!( result_data.get("secret_a_leaked").unwrap(), &serde_json::json!(false), "Secret from previous action should not leak" ); assert_eq!( result_data.get("secret_b_present").unwrap(), &serde_json::json!(true), "Current action's secret should be present" ); } #[tokio::test] async fn test_python_empty_secrets() { let tmp = TempDir::new().unwrap(); let runtime = make_python_process_runtime(tmp.path().to_path_buf()); // With no secrets, stdin should have nothing (or empty) — action should still work let code = r#" print("ok") "#; let context = ExecutionContext { execution_id: 5, action_ref: "security.no_secrets".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: HashMap::new(), timeout: Some(10), working_dir: None, entry_point: "inline".to_string(), code: Some(code.to_string()), code_path: None, runtime_name: Some("python".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::default(), }; let result = runtime.execute(context).await.unwrap(); assert_eq!( result.exit_code, 0, "Should handle empty secrets gracefully. stderr: {}", result.stderr ); assert!( result.stdout.contains("ok"), "Should produce expected output. stdout: {}", result.stdout ); } #[tokio::test] async fn test_shell_empty_secrets() { let runtime = ShellRuntime::new(); let context = ExecutionContext { execution_id: 6, action_ref: "security.no_secrets".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: HashMap::new(), timeout: Some(10), working_dir: None, entry_point: "shell".to_string(), code: Some( r#" # get_secret should return empty string for non-existent secrets result=$(get_secret 'nonexistent') if [ -z "$result" ]; then echo "PASS: Empty secret returns empty string" else echo "FAIL: Expected empty string" exit 1 fi "# .to_string(), ), code_path: None, runtime_name: Some("shell".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::default(), }; let result = runtime.execute(context).await.unwrap(); assert!( result.is_success(), "Should handle empty secrets gracefully. stderr: {}", result.stderr ); assert!( result.stdout.contains("PASS"), "Should pass. stdout: {}", result.stdout ); } #[tokio::test] async fn test_process_runtime_secrets_not_in_environ() { // Verify ProcessRuntime (used for all runtimes now) doesn't leak secrets to env let tmp = TempDir::new().unwrap(); let pack_dir = tmp.path().join("testpack"); let actions_dir = pack_dir.join("actions"); std::fs::create_dir_all(&actions_dir).unwrap(); // Write a script that dumps environment std::fs::write( actions_dir.join("check_env.sh"), r#"#!/bin/bash if printenv | grep -q "SUPER_SECRET_VALUE"; then echo "FAIL: Secret leaked to environment" exit 1 fi echo "PASS: No secrets in environment" "#, ) .unwrap(); let config = RuntimeExecutionConfig { interpreter: InterpreterConfig { binary: "/bin/bash".to_string(), args: vec![], file_extension: Some(".sh".to_string()), }, environment: None, dependencies: None, }; let runtime = ProcessRuntime::new("shell".to_string(), config, tmp.path().to_path_buf(), tmp.path().join("runtime_envs")); let context = ExecutionContext { execution_id: 7, action_ref: "testpack.check_env".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: { let mut s = HashMap::new(); s.insert("db_password".to_string(), "SUPER_SECRET_VALUE".to_string()); s }, timeout: Some(10), working_dir: None, entry_point: "check_env.sh".to_string(), code: None, code_path: Some(actions_dir.join("check_env.sh")), runtime_name: Some("shell".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::default(), }; let result = runtime.execute(context).await.unwrap(); assert_eq!( result.exit_code, 0, "Check should pass. stdout: {}, stderr: {}", result.stdout, result.stderr ); assert!( result.stdout.contains("PASS"), "Should confirm no secrets in env. stdout: {}", result.stdout ); } #[tokio::test] async fn test_python_process_runtime_secrets_not_in_environ() { // Same check but via ProcessRuntime with Python interpreter let tmp = TempDir::new().unwrap(); let pack_dir = tmp.path().join("testpack"); let actions_dir = pack_dir.join("actions"); std::fs::create_dir_all(&actions_dir).unwrap(); std::fs::write( actions_dir.join("check_env.py"), r#" import os, json env_dump = str(os.environ) leaked = "TOP_SECRET_API_KEY" in env_dump print(json.dumps({"leaked": leaked})) "#, ) .unwrap(); let config = RuntimeExecutionConfig { interpreter: InterpreterConfig { binary: "python3".to_string(), args: vec!["-u".to_string()], file_extension: Some(".py".to_string()), }, environment: None, dependencies: None, }; let runtime = ProcessRuntime::new("python".to_string(), config, tmp.path().to_path_buf(), tmp.path().join("runtime_envs")); let context = ExecutionContext { execution_id: 8, action_ref: "testpack.check_env".to_string(), parameters: HashMap::new(), env: HashMap::new(), secrets: { let mut s = HashMap::new(); s.insert("api_key".to_string(), "TOP_SECRET_API_KEY".to_string()); s }, timeout: Some(10), working_dir: None, entry_point: "check_env.py".to_string(), code: None, code_path: Some(actions_dir.join("check_env.py")), runtime_name: Some("python".to_string()), max_stdout_bytes: 10 * 1024 * 1024, max_stderr_bytes: 10 * 1024 * 1024, parameter_delivery: attune_worker::runtime::ParameterDelivery::default(), parameter_format: attune_worker::runtime::ParameterFormat::default(), output_format: attune_worker::runtime::OutputFormat::Json, }; let result = runtime.execute(context).await.unwrap(); assert_eq!( result.exit_code, 0, "Python env check should succeed. stderr: {}", result.stderr ); let result_data = result.result.expect("Should have parsed JSON result"); assert_eq!( result_data.get("leaked").unwrap(), &serde_json::json!(false), "SECURITY FAILURE: Secret leaked to Python process environment!" ); }