//! 脚本节点 — 执行 Shell 脚本或自定义命令 use async_trait::async_trait; use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema}; /// 脚本节点 pub struct ScriptNode; #[async_trait] impl Node for ScriptNode { async fn execute(&self, ctx: NodeContext) -> NodeResult { tracing::info!("ScriptNode 执行: {}", ctx.node_id); // 从 config 解析 command(必填) let command = ctx .config .get("command") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("ScriptNode 缺少必填参数: command"))?; // 解析可选参数 let timeout_secs = ctx .config .get("timeout_secs") .and_then(|v| v.as_u64()); let working_dir = ctx .config .get("working_dir") .and_then(|v| v.as_str()) .map(|s| s.to_string()); // 构建请求并执行 let request = df_execute::shell::ShellRequest { command: command.to_string(), working_dir, env: std::collections::HashMap::new(), timeout_secs, shell_type: Default::default(), }; // 命令执行安全:白/黑名单校验(从环境变量读取,逗号分隔命令名)。 // - 白名单非空时:命令首词不在白名单 → 直接拒绝执行 // - 黑名单匹配时:直接拒绝执行 // 命令名取首词(shell 第一段,如 `rm -rf /` 取 `rm`),按 trim + 小写规范化比较。 let cmd_name = command.split_whitespace().next().unwrap_or("").to_lowercase(); if let Some(denied) = check_command_policy(&cmd_name) { tracing::warn!( command = %command, reason = %denied, "ScriptNode 命令被策略拒绝" ); anyhow::bail!("脚本命令被策略拒绝: {} (命令: {})", denied, command); } // 危险命令告警:匹配已知危险关键词,仅告警不阻止执行 let dangerous_keywords = ["rm -rf", "DROP TABLE", "Format", "del /f", "shutdown"]; for &kw in &dangerous_keywords { if command.contains(kw) { tracing::warn!( keyword = %kw, command = %command, "ScriptNode 即将执行包含危险关键词的命令" ); } } tracing::info!("ScriptNode 执行命令: {}", command); let result = df_execute::shell::execute(request).await?; // 非零退出码视为执行失败 let exit_code = result.exit_code.unwrap_or(-1); if exit_code != 0 { anyhow::bail!( "脚本执行失败 (exit_code={}): {}", exit_code, result.stderr.trim() ); } tracing::info!( "ScriptNode 完成: 耗时 {}ms, exit_code={}", result.duration_ms, exit_code ); Ok(NodeOutput::from_value(serde_json::json!({ "stdout": result.stdout, "stderr": result.stderr, "exit_code": exit_code, "duration_ms": result.duration_ms, }))) } fn schema(&self) -> NodeSchema { NodeSchema { params: serde_json::json!({ "type": "object", "properties": { "command": { "type": "string" }, "timeout_secs": { "type": "integer" }, "working_dir": { "type": "string" } }, "required": ["command"] }), output: serde_json::json!({ "type": "object", "properties": { "stdout": { "type": "string" }, "stderr": { "type": "string" }, "exit_code": { "type": "integer" }, "duration_ms": { "type": "integer" } } }), } } fn node_type(&self) -> &str { "script" } } /// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。 /// /// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。 /// /// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝 /// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝 /// /// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。 /// /// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。 fn check_command_policy(cmd_name: &str) -> Option<&'static str> { // 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝(防止白名单失误放过危险命令) if let Ok(blacklist_raw) = std::env::var("DF_SCRIPT_BLACKLIST") { let blacklist: Vec = blacklist_raw .split(',') .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .collect(); if blacklist.iter().any(|c| c == cmd_name) { return Some("命令在黑名单中 (DF_SCRIPT_BLACKLIST)"); } } // 白名单:非空时命令名必须在白名单中才放行 if let Ok(whitelist_raw) = std::env::var("DF_SCRIPT_WHITELIST") { let whitelist: Vec = whitelist_raw .split(',') .map(|s| s.trim().to_lowercase()) .filter(|s| !s.is_empty()) .collect(); if !whitelist.is_empty() && !whitelist.iter().any(|c| c == cmd_name) { return Some("命令不在白名单中 (DF_SCRIPT_WHITELIST)"); } } None }