feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper

stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
This commit is contained in:
AskaEth committed 2026-10-26 13:17:42 +08:00
1 parent 240b59708d
commit 003d12a712
18 files changed
+1612 -71

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
package trace
import (
"net/url"
"strings"
)
// 本文件是「URL 只以脱敏形态进日志」的两个唯一工具。它们的职责分开且都不回原串:
//
// - HostOf:只回来源(scheme + host),用于「这次请求打到哪」;
// - MaskQuery:回一个可读的 URL,但凭据类 query 值(OAuth token、签名、API key)
// 已被打码,用于排查「带查询参数的请求」。
//
// 为什么要专门有两个函数:平台的资源地址几乎都带签名(预签名 URL、CDN token、
// OAuth 回调),把 URL 原串写进日志等于把凭据写进日志 —— 而日志会被采集、tail、
// 粘进工单,是最容易扩散的面。任何「顺手 log 一下 URL」的地方都必须经过这里。
//
// 与 mediafetch.HostOf 的关系:那个是「入站附件取回」的同口径工具,但它落在
// channel 层(带 HTTP 客户端与来源白名单),trace 不能反向依赖它,否则观测量
// 就会拖着一个渠道下载器。因此这里是**零依赖的那一份**,口径刻意与它一致
// (只回 scheme://host、绝不回路径与查询串);差异只有一处:带 user:pass@ 时
// 本函数把用户信息整段打码成 [redacted],而 mediafetch 直接丢掉用户信息。
// 两者都不回凭据,因此不存在「一份安全一份不安全」的分叉;将来若要把
// mediafetch.HostOf 改成本函数的薄封装,属于 channel 层的事(不在本切片文件面内)。
// maskedQueryMarker 是被打码的 query 值。它刻意**不含**需要转义的字符,因此
// 打码后的 URL 仍是肉眼可读的形状(access_token=***),而不是一堆 %2A。
const maskedQueryMarker = "***"
// maskedUserInfo 替换 URL 里整段 user:pass@ 用户信息。宁可丢掉用户名也不半遮半掩
// ——半遮半掩的实现最容易在某个分支上漏出密码。
const maskedUserInfo = "[redacted]"
// secretQueryMarkers 是「这个 query/片段参数的值是凭据」的判据:键名归一后
// 命中 IsSecretKey,或含这些子串(覆盖 sig/sign/signature/hmac/credential 等
// 签名类参数 —— 预签名 URL 的授权就在这些值里)。
var secretQueryMarkers = []string{"token", "secret", "key", "sig", "sign", "credential", "password", "passwd"}
// HostOf 返回 rawURL 的来源(scheme://host,含端口),用于日志里的「打到哪」。
//
// 三条口径:
// - 绝不回完整 URL:路径与查询串一律不出(它们才带内容与签名);
// - 带 user:pass@ 时**整段用户信息打码**(maskedUserInfo),因此
// "https://user:pass@h/x" 得到 "https://[redacted]@h";
// - 解析不出 host(相对路径、空串、畸形串)时返回空串 —— 绝不回落到原串。
func HostOf(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Host == "" {
return ""
}
return maskedOrigin(u)
}
// MaskQuery 返回 rawURL 的脱敏形态:凭据类 query / 片段参数的值被替换为
// maskedQueryMarker,其余原样保留(保序、不重新编码,便于肉眼比对)。
//
// 三条口径:
// - 解析不出 host 时返回空串 —— 绝不回落到原串(回落就是泄漏);
// - user:pass@ 同样整段打码(MaskQuery 的回串也是要进日志的);
// - 片段(fragment)按同一套判据打码:OAuth 隐式流把 access_token 放在 # 之后。
func MaskQuery(rawURL string) string {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Host == "" {
return ""
}
var b strings.Builder
b.WriteString(maskedOrigin(u))
b.WriteString(u.EscapedPath())
if masked := maskPairs(u.RawQuery); masked != "" {
b.WriteString("?")
b.WriteString(masked)
}
if u.Fragment != "" {
b.WriteString("#")
b.WriteString(maskPairs(u.Fragment))
}
return b.String()
}
// maskedOrigin 渲染 scheme://host,用户信息存在时整段打码。
func maskedOrigin(u *url.URL) string {
base := "//"
if u.Scheme != "" {
base = u.Scheme + "://"
}
if u.User != nil {
return base + maskedUserInfo + "@" + u.Host
}
return base + u.Host
}
// maskPairs 对形如 a=1&b=2 的串逐个参数打码:键归一后命中凭据判据的参数,
// 其值整段替换为 maskedQueryMarker。没有 "=" 的段(裸标记)原样保留。
func maskPairs(raw string) string {
if raw == "" {
return raw
}
parts := strings.Split(raw, "&")
for i, part := range parts {
key, _, ok := strings.Cut(part, "=")
if !ok {
continue
}
if isSecretQueryKey(key) {
parts[i] = key + "=" + maskedQueryMarker
}
}
return strings.Join(parts, "&")
}
// isSecretQueryKey 报告某个 query/fragment 参数名是否命名了凭据。键先做 URL 解码
// 与大小写/分隔符归一,因此 access%5Ftoken 与 ACCESS-TOKEN 同样命中。
func isSecretQueryKey(key string) bool {
decoded, err := url.QueryUnescape(key)
if err != nil {
decoded = key
}
k := strings.NewReplacer("-", "_", ".", "_", "+", "_").Replace(strings.ToLower(strings.TrimSpace(decoded)))
if IsSecretKey(k) {
return true
}
for _, marker := range secretQueryMarkers {
if strings.Contains(k, marker) {
return true
}
}
return false
}