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 全绿。
129 lines
5.1 KiB
Go
129 lines
5.1 KiB
Go
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
|
||
}
|