service/log_sanitize.go

243 lines
5.5 KiB
Go
Raw Normal View History

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
}