Files
Cyrene_v2/internal/runtime/trace/urlmask.go
T
AskaEth 003d12a712 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 全绿。
2026-10-26 13:17:42 +08:00

129 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}