- 审批超时: Settings 下拉选择(不限时/5/15/30/60min),默认 15min - getApprovalTimeoutMs() 从 KV 读取,startApprovalTimer 动态取值 - ScriptNode: 黑名单 > 白名单策略,从环境变量读取 - Settings 加命令执行安全面板(白/黑名单文本框) - i18n 中英文案 + 搜索索引补全
160 lines
5.7 KiB
Rust
160 lines
5.7 KiB
Rust
//! 脚本节点 — 执行 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<String> = 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<String> = 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
|
|
}
|