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 }