service/log_sanitize.go
AI Engineer e8369d4680 feat(service): Client Key 应答头条件化,静态文件/WebSocket 仅 Cookie 维护,配置字段命名统一(by AI)
- Device-Id/Session-Id 仅当请求头未携带时才写入应答头
- 静态文件和 WebSocket 升级应答仅通过 Cookie 维护身份
- Client App 头改为 App-Name/App-Version(破折号命名)
- NoLogHeaders → NoLogRequestHeaders,NoLogOutputFields → NoLogResponseFields,新增 NoLogResponseHeaders
- 默认排除列表动态构建,用户只需追加自定义字段
- Cookie 头智能过滤:不再整体排除,仅剔除匹配排除列表的 key

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-06-22 19:01:53 +08:00

243 lines
5.5 KiB
Go
Raw Permalink 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 service
import (
"net/http"
"sort"
"strings"
"apigo.cc/go/cast"
)
// sanitizeOpts 日志脱敏配置
type sanitizeOpts struct {
maxSize int // 整体尺寸上限(内容字符估算)
fieldSize int // 单字符串截断长度
arrayNum int // 数组最多保留元素数
objectNum int // 对象最多保留 key 数
}
// sanitizeLogData 递归脱敏,返回新建的对象,不影响原始数据
func sanitizeLogData(v any, opts sanitizeOpts) any {
budget := opts.maxSize
return sanitizeRecursive(v, opts, &budget)
}
func sanitizeRecursive(v any, opts sanitizeOpts, budget *int) any {
if v == nil {
return nil
}
switch val := v.(type) {
case string:
return sanitizeString(val, opts, budget)
case bool:
return sanitizeScalar(val, 5, budget)
case float64:
return sanitizeScalar(val, 8, budget)
case map[string]any:
return sanitizeMapContent(val, opts, budget)
case []any:
return sanitizeSliceContent(val, opts, budget)
default:
// int 等各种数值类型
return sanitizeScalar(val, 8, budget)
}
}
func sanitizeString(s string, opts sanitizeOpts, budget *int) string {
if len([]rune(s)) > opts.fieldSize {
s = string([]rune(s)[:opts.fieldSize])
}
if *budget < len(s) {
*budget = 0
return s // 即使超出预算也返回截断后的内容
}
*budget -= len(s)
return s
}
func sanitizeScalar(v any, cost int, budget *int) any {
if *budget < cost {
*budget = 0
return nil
}
*budget -= cost
return v
}
func sanitizeMapContent(m map[string]any, opts sanitizeOpts, budget *int) map[string]any {
// 空 map 占 2 预算
if *budget < 2 {
*budget = 0
return map[string]any{}
}
*budget -= 2
result := make(map[string]any)
// 排序 key 保证遍历顺序确定性
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
count := 0
for _, k := range keys {
if count >= opts.objectNum || *budget <= 0 {
break
}
// 扣 key 长度
keyCost := len(k)
if *budget < keyCost {
break
}
budgetBefore := *budget
*budget -= keyCost
// 递归处理 value
processed := sanitizeRecursive(m[k], opts, budget)
// 检查是否因预算不足返回了 nil仅当原值非 nil 时)
if processed == nil && m[k] != nil {
*budget = budgetBefore
break
}
result[k] = processed
count++
}
return result
}
func sanitizeSliceContent(s []any, opts sanitizeOpts, budget *int) []any {
// 空 slice 占 2 预算
if *budget < 2 {
*budget = 0
return []any{}
}
*budget -= 2
result := make([]any, 0, min(len(s), opts.arrayNum))
count := 0
for _, v := range s {
if count >= opts.arrayNum || *budget <= 0 {
break
}
// 值预算由递归处理
processed := sanitizeRecursive(v, opts, budget)
if processed == nil && v != nil {
break
}
result = append(result, processed)
count++
}
return result
}
// sanitizeLogHeaders 过滤请求/响应头,排除 noLogHeaders 中指定的字段。
// 对于 Cookie 头,不整体排除,而是从 Cookie 内容中剔除 key 命中排除列表的键值对。
func sanitizeLogHeaders(h http.Header, noLogHeaders string) map[string]string {
result := make(map[string]string)
excludes := splitAndTrim(noLogHeaders)
for k, v := range h {
if k == "Cookie" && len(excludes) > 0 {
if filtered := sanitizeCookieValue(v, excludes); filtered != "" {
result[k] = filtered
}
continue
}
skip := false
for _, ex := range excludes {
if strings.EqualFold(k, ex) {
skip = true
break
}
}
if !skip {
result[k] = strings.Join(v, ", ")
}
}
return result
}
// sanitizeCookieValue 从 Cookie 原始值中剔除 key 命中排除列表的键值对。
// 输入为原始 Cookie 头值(可能多个 key=value 以 "; " 或 ";" 分隔)。
func sanitizeCookieValue(values []string, excludes []string) string {
raw := strings.Join(values, "; ")
pairs := strings.Split(raw, ";")
var kept []string
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
key, _, _ := strings.Cut(pair, "=")
key = strings.TrimSpace(key)
excluded := false
for _, ex := range excludes {
if strings.EqualFold(key, ex) {
excluded = true
break
}
}
if !excluded {
kept = append(kept, pair)
}
}
return strings.Join(kept, "; ")
}
func splitAndTrim(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
for i, p := range parts {
parts[i] = strings.TrimSpace(p)
}
return parts
}
// sanitizeRespBody 对响应体进行脱敏:尝试 JSON 解析后走对象脱敏,失败则按字符串截断
func sanitizeRespBody(body []byte, cfg *ServiceConfig) any {
// 尝试解析为 JSON 对象
var parsed any
if err := cast.UnmarshalJSON(body, &parsed); err == nil && parsed != nil {
// 排除敏感字段
if cfg.NoLogResponseFields != "" {
parsed = stripFields(parsed, cfg.NoLogResponseFields)
}
return sanitizeLogData(parsed, sanitizeOpts{
maxSize: 200,
fieldSize: cfg.LogOutputFieldSize,
arrayNum: cfg.LogOutputArrayNum,
objectNum: cfg.LogOutputObjectNum,
})
}
// 非 JSON 内容,按字符串截断
if len(body) > cfg.LogOutputMaxSize {
return string(body[:cfg.LogOutputMaxSize]) + "..."
}
return string(body)
}
// stripFields 从对象中删除指定字段(仅处理顶层 map
func stripFields(v any, fields string) any {
m, ok := v.(map[string]any)
if !ok {
return v
}
excludes := strings.Split(fields, ",")
for _, f := range excludes {
f = strings.TrimSpace(f)
if f != "" {
delete(m, f)
}
}
return m
}