fix(security): P0 安全修复 — 审计日志 PII 脱敏 + AI Token 计量 + backup.sh 拼写 + CI audit
1. 审计日志 PII 脱敏: audit_service.rs 中 old_value/new_value 自动 mask patient/consultation/follow_up 等资源类型的 PII 字段(id_number/phone/name 等) 2. AI Token 计量: chat_handler.rs 从 Provider response 和 AgentOrchestrator 提取 实际 input_tokens/output_tokens,替代硬编码 0 3. AI display_hints: 从 AgentOrchestrator 传递 display_hints 给前端 ChatResponse 4. backup.sh: PGDATABSE 拼写错误修复为 PGDATABASE 5. CI: npm audit 移除 || true,高危漏洞阻止合并 6. 新增六维度深度分析报告 docs/discussions/2026-05-28
This commit is contained in:
@@ -240,6 +240,14 @@ where
|
||||
let provider_name = provider_arc.name().to_string();
|
||||
let supports_fc = provider_name != "ollama"; // Ollama generate_with_tools 未实现
|
||||
|
||||
// 收集 token 和 display_hints
|
||||
#[allow(unused_assignments)]
|
||||
let mut input_tokens: u32 = 0;
|
||||
#[allow(unused_assignments)]
|
||||
let mut output_tokens: u32 = 0;
|
||||
let mut duration_ms: u64 = 0;
|
||||
let mut collected_hints: Option<Vec<crate::agent::tool::DisplayHint>> = None;
|
||||
|
||||
let result = if supports_fc {
|
||||
// FC provider:执行完整 Agent ReAct 循环
|
||||
let orchestrator = AgentOrchestrator::new(provider_arc, std::sync::Arc::new(registry));
|
||||
@@ -256,6 +264,11 @@ where
|
||||
tracing::error!(error = %e, "AI Agent run failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
input_tokens = agent_result.total_input_tokens;
|
||||
output_tokens = agent_result.total_output_tokens;
|
||||
if !agent_result.display_hints.is_empty() {
|
||||
collected_hints = Some(agent_result.display_hints);
|
||||
}
|
||||
agent_result.reply
|
||||
} else {
|
||||
// 非 FC provider:降级为普通对话
|
||||
@@ -279,6 +292,9 @@ where
|
||||
tracing::error!(error = %e, "AI generate failed");
|
||||
erp_core::error::AppError::Internal("AI 服务暂时不可用,请稍后再试".into())
|
||||
})?;
|
||||
input_tokens = resp.input_tokens;
|
||||
output_tokens = resp.output_tokens;
|
||||
duration_ms = resp.duration_ms;
|
||||
resp.content
|
||||
};
|
||||
|
||||
@@ -297,7 +313,7 @@ where
|
||||
"AI chat response sent"
|
||||
);
|
||||
|
||||
// 记录用量的 token 消耗(简化模式下无法精确计量,记 0)
|
||||
// 记录用量的 token 消耗
|
||||
if let Err(e) = ai_state
|
||||
.usage
|
||||
.log_usage(
|
||||
@@ -305,9 +321,9 @@ where
|
||||
&provider_name,
|
||||
&run_params.model,
|
||||
"chat",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
duration_ms,
|
||||
0,
|
||||
false,
|
||||
)
|
||||
@@ -362,7 +378,7 @@ where
|
||||
reply,
|
||||
message_id,
|
||||
iterations: if supports_fc { 1 } else { 0 },
|
||||
display_hints: None,
|
||||
display_hints: collected_hints,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,66 @@ use sha2::{Digest, Sha256};
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 审计日志中需要脱敏的 PII 字段名(小写匹配)
|
||||
const PII_FIELDS: &[&str] = &[
|
||||
"id_number",
|
||||
"phone",
|
||||
"emergency_contact_phone",
|
||||
"emergency_contact_name",
|
||||
"allergy_history",
|
||||
"medical_history_summary",
|
||||
"name",
|
||||
"content",
|
||||
];
|
||||
|
||||
/// 审计日志中需要脱敏的 resource_type 前缀
|
||||
const PII_RESOURCE_TYPES: &[&str] = &[
|
||||
"patient",
|
||||
"consultation",
|
||||
"follow_up",
|
||||
"family_member",
|
||||
"doctor_profile",
|
||||
];
|
||||
|
||||
/// 对 JSON Value 中的 PII 字段进行脱敏
|
||||
fn sanitize_audit_value(
|
||||
value: &Option<serde_json::Value>,
|
||||
resource_type: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let needs_sanitization = PII_RESOURCE_TYPES
|
||||
.iter()
|
||||
.any(|prefix| resource_type.starts_with(prefix));
|
||||
|
||||
if !needs_sanitization {
|
||||
return value.clone();
|
||||
}
|
||||
|
||||
value.as_ref().map(sanitize_json_value)
|
||||
}
|
||||
|
||||
fn sanitize_json_value(v: &serde_json::Value) -> serde_json::Value {
|
||||
match v {
|
||||
serde_json::Value::Object(map) => {
|
||||
let sanitized: serde_json::Map<String, serde_json::Value> = map
|
||||
.into_iter()
|
||||
.map(|(k, v)| {
|
||||
let key_lower = k.to_lowercase();
|
||||
if PII_FIELDS.iter().any(|f| key_lower.contains(f)) {
|
||||
(k.clone(), serde_json::Value::String("***".to_string()))
|
||||
} else {
|
||||
(k.clone(), sanitize_json_value(v))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Object(sanitized)
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
serde_json::Value::Array(arr.iter().map(sanitize_json_value).collect())
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
///
|
||||
/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。
|
||||
@@ -43,6 +103,10 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 计算当前记录的 record_hash
|
||||
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
|
||||
|
||||
// 脱敏处理:对 patient/consultation/follow_up 等资源类型的变更值中 PII 字段进行 mask
|
||||
let sanitized_old = sanitize_audit_value(&log.old_value, &log.resource_type);
|
||||
let sanitized_new = sanitize_audit_value(&log.new_value, &log.resource_type);
|
||||
|
||||
// 保存日志字段用于错误日志(model 构建会 move String 字段)
|
||||
let err_tenant_id = log.tenant_id;
|
||||
let err_action = log.action.clone();
|
||||
@@ -56,8 +120,8 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
action: Set(log.action),
|
||||
resource_type: Set(log.resource_type),
|
||||
resource_id: Set(log.resource_id),
|
||||
old_value: Set(log.old_value),
|
||||
new_value: Set(log.new_value),
|
||||
old_value: Set(sanitized_old),
|
||||
new_value: Set(sanitized_new),
|
||||
ip_address: Set(log.ip_address),
|
||||
user_agent: Set(log.user_agent),
|
||||
created_at: Set(log.created_at),
|
||||
|
||||
Reference in New Issue
Block a user