fix(security): 工具输出与参数会原样写进日志 —— 落盘前脱敏

## 缺陷

两处日志把工具内容原样打印,没有经过 security.Replace:

	task.go:1038  log.Printf("[agent] tool %s result: %s", ..., truncateStr(result, 100))
	task.go:967   log.Printf("[agent] tool %s raw_arguments: %s", ..., truncateStr(tc.RawArguments, 300))

实测场景:enc_shell 跑 curl 拿到 200,响应体里带着上游 key 的完整值
(`{"key":"sk-..."}`),这一行直接写进 /home/newqqagent/log/homed_*.log。

## 为什么日志泄漏比上下文泄漏更严重

	模型上下文  短期——一轮结束就不再发送
	日志        长期——每天一个文件、从不自动清理,systemd journal 另存一份,
	                   排查问题时还会被贴进 issue

一次落盘就是永久泄漏。凭据轮换前,任何拿到日志的人都能直接用。

## 改动

 · 新增 scrubForLog(text, where):唯一入口,空安全(Global()==nil 时原样返回)
 · 两处日志调用点都改为先 scrubForLog 再落盘
 · 拦截本身记一笔(只记模式与长度,不记原文),便于事后发现"哪个工具常吐凭据"

## 判据(4 条)

 · 明文被换成别名(真 Store,不用 mock)
 · 普通文本不被改动(不误伤)
 · Global() 为 nil 时不 panic(安全模块未启用是常态)
 · **接线**:两处调用点确实走了 scrubForLog(只测函数不测接线会漏掉原缺陷)

变异:scrubForLog 直接返回原文 → 咬;工具结果日志去掉 scrubForLog → 咬。
This commit is contained in:
JianFeeeee
2026-10-18 13:20:54 +08:00
parent d9c2e51f61
commit 59f7001932
4 changed files with 200 additions and 2 deletions

View File

@ -0,0 +1,60 @@
package core
// 日志脱敏:写进日志的文本必须先清洗明文凭据。
//
// ## 为什么这条比"拦住模型"更紧迫
//
// 模型上下文是**短期**的:一轮对话结束就不再发送。
// 而日志是**长期**的:
//
// /home/newqqagent/log/homed_*.log 每天一个文件,从不自动清理
// systemd journal 另存一份
// 用户/技术支持/issue 排查时会把日志贴出来
//
// 一次泄漏进日志 = 永久泄漏。凭据被轮换前,任何拿到日志的人都能用。
//
// ## 具体泄漏点(2026-10-18 审计)
//
// task.go:1031 log.Printf("[agent] tool %s result: %s", ..., truncateStr(result, 100))
// ← 工具输出可能含服务端回显的凭据(enc_shell 跑 curl 拿 200)
// task.go:967 log.Printf("[agent] tool %s raw_arguments: %s", ..., truncateStr(tc.RawArguments, 300))
// ← 模型给的原始参数。正常是别名,但插件工具可能被直接传明文
//
// 两处都**原样打印**,没有经过 security.Replace。
//
// ## 实现
//
// scrubForLog 是唯一入口,所有"把工具内容写进日志"的地方都走它。
// 空安全:security.Global() 未初始化时原样返回(不 panic、不阻塞)。
// 幂等:已是别名(sec_xxx)的文本再扫一次不会被改动。
import (
"log"
"github.com/JianFeeeee/HomeAgent/internal/security"
)
// scrubForLog 清洗要写进日志的文本。
//
// where 用于日志里标注拦截位置(如 "tool_result:enc_shell"),
// 便于事后审计"哪些工具的输出带凭据"。
func scrubForLog(text, where string) string {
if text == "" {
return text
}
st := security.Global()
if st == nil {
return text
}
out, evts := st.Replace(text, "log:"+where, security.Patterns)
if len(evts) == 0 {
return text
}
// 拦截本身要记一笔(不打印原文,只打印模式与长度)——
// 否则运维不知道"这个工具经常吐凭据",也就不会去修插件。
for _, e := range evts {
log.Printf("[security] 日志拦截 %s ← %s(%d 字节),原文未落盘",
where, e.Kind, e.Len)
}
return out
}