实现七月迭代公共技术基础
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s
This commit is contained in:
@@ -2,6 +2,8 @@ package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,20 +17,38 @@ import (
|
||||
const (
|
||||
// MaxBodyLogSize 限制记录的请求/响应 body 大小为 50KB
|
||||
MaxBodyLogSize = 50 * 1024
|
||||
redactedValue = "[已脱敏]"
|
||||
)
|
||||
|
||||
// BodyPolicy 表示访问日志正文记录策略。
|
||||
type BodyPolicy int
|
||||
|
||||
const (
|
||||
// BodyPolicyJSON 表示只允许记录脱敏后的 JSON 正文。
|
||||
BodyPolicyJSON BodyPolicy = iota + 1
|
||||
// BodyPolicySummary 表示只记录不可逆安全摘要。
|
||||
BodyPolicySummary
|
||||
)
|
||||
|
||||
// SanitizedContent 表示访问日志可安全记录的正文或查询摘要。
|
||||
type SanitizedContent struct {
|
||||
Content string
|
||||
Size int
|
||||
SHA256 string
|
||||
Truncated bool
|
||||
}
|
||||
|
||||
// truncateBody 截断 body 到指定大小
|
||||
func truncateBody(body []byte, maxSize int) string {
|
||||
func truncateBody(body []byte, maxSize int) (string, bool) {
|
||||
if len(body) == 0 {
|
||||
return ""
|
||||
return "", false
|
||||
}
|
||||
|
||||
if len(body) <= maxSize {
|
||||
return string(body)
|
||||
return string(body), false
|
||||
}
|
||||
|
||||
// 超过限制,截断并添加提示
|
||||
return string(body[:maxSize]) + "... (truncated)"
|
||||
return string(body[:maxSize]), true
|
||||
}
|
||||
|
||||
// maskSensitiveValue 按字段名判断并脱敏访问日志中的敏感值
|
||||
@@ -36,25 +56,20 @@ func maskSensitiveValue(key, value string) string {
|
||||
if value == "" {
|
||||
return value
|
||||
}
|
||||
normalized := strings.ToLower(key)
|
||||
if strings.Contains(normalized, "password") ||
|
||||
strings.Contains(normalized, "sign") ||
|
||||
strings.Contains(normalized, "nonce") ||
|
||||
strings.Contains(normalized, "token") ||
|
||||
strings.Contains(normalized, "secret") {
|
||||
return "***"
|
||||
if shouldMaskField(key) {
|
||||
return redactedValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// sanitizeQuery 脱敏 query 中的密码、签名、nonce、token 等字段
|
||||
func sanitizeQuery(rawQuery string) string {
|
||||
func sanitizeQuery(rawQuery string) SanitizedContent {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
return SanitizedContent{}
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return rawQuery
|
||||
return summarize([]byte(rawQuery))
|
||||
}
|
||||
for key, items := range values {
|
||||
for index, item := range items {
|
||||
@@ -62,24 +77,38 @@ func sanitizeQuery(rawQuery string) string {
|
||||
}
|
||||
values[key] = items
|
||||
}
|
||||
return values.Encode()
|
||||
content, truncated := truncateBody([]byte(values.Encode()), MaxBodyLogSize)
|
||||
return SanitizedContent{Content: content, Size: len(rawQuery), SHA256: digest([]byte(rawQuery)), Truncated: truncated}
|
||||
}
|
||||
|
||||
// sanitizeBody 脱敏 JSON 请求体后再写入访问日志
|
||||
func sanitizeBody(rawBody []byte) string {
|
||||
// sanitizeBody 按策略脱敏正文后再写入访问日志。
|
||||
func sanitizeBody(rawBody []byte, policy BodyPolicy) SanitizedContent {
|
||||
if len(rawBody) == 0 {
|
||||
return ""
|
||||
return SanitizedContent{}
|
||||
}
|
||||
if policy == BodyPolicySummary {
|
||||
return summarize(rawBody)
|
||||
}
|
||||
var payload any
|
||||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||
return truncateBody(rawBody, MaxBodyLogSize)
|
||||
return summarize(rawBody)
|
||||
}
|
||||
sanitizeJSONValue(payload)
|
||||
data, err := sonic.Marshal(payload)
|
||||
if err != nil {
|
||||
return truncateBody(rawBody, MaxBodyLogSize)
|
||||
return summarize(rawBody)
|
||||
}
|
||||
return truncateBody(data, MaxBodyLogSize)
|
||||
content, truncated := truncateBody(data, MaxBodyLogSize)
|
||||
return SanitizedContent{Content: content, Size: len(rawBody), SHA256: digest(rawBody), Truncated: truncated}
|
||||
}
|
||||
|
||||
func summarize(raw []byte) SanitizedContent {
|
||||
return SanitizedContent{Content: "[仅记录安全摘要]", Size: len(raw), SHA256: digest(raw), Truncated: len(raw) > MaxBodyLogSize}
|
||||
}
|
||||
|
||||
func digest(raw []byte) string {
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// sanitizeJSONValue 递归脱敏 JSON 对象中的敏感字段
|
||||
@@ -92,7 +121,7 @@ func sanitizeJSONValue(value any) {
|
||||
continue
|
||||
}
|
||||
if shouldMaskField(key) {
|
||||
typed[key] = "***"
|
||||
typed[key] = redactedValue
|
||||
continue
|
||||
}
|
||||
sanitizeJSONValue(item)
|
||||
@@ -108,6 +137,14 @@ func sanitizeJSONValue(value any) {
|
||||
func shouldMaskField(key string) bool {
|
||||
normalized := strings.ToLower(key)
|
||||
return strings.Contains(normalized, "password") ||
|
||||
strings.Contains(normalized, "passwd") ||
|
||||
strings.Contains(normalized, "credential") ||
|
||||
strings.Contains(normalized, "authorization") ||
|
||||
strings.Contains(normalized, "cookie") ||
|
||||
strings.Contains(normalized, "key") ||
|
||||
strings.Contains(normalized, "url") ||
|
||||
strings.Contains(normalized, "qr_content") ||
|
||||
strings.Contains(normalized, "verification_code") ||
|
||||
strings.Contains(normalized, "sign") ||
|
||||
strings.Contains(normalized, "nonce") ||
|
||||
strings.Contains(normalized, "token") ||
|
||||
@@ -117,6 +154,12 @@ func shouldMaskField(key string) bool {
|
||||
// Middleware 创建 Fiber 日志中间件
|
||||
// 记录所有 HTTP 请求到访问日志(包括请求和响应 body)
|
||||
func Middleware() fiber.Handler {
|
||||
return MiddlewareWithLogger(GetAccessLogger())
|
||||
}
|
||||
|
||||
// MiddlewareWithLogger 创建可注入访问日志器的 Fiber 中间件。
|
||||
// 生产环境使用 Middleware;该入口让集成测试捕获最终 JSON 日志而无需修改全局状态。
|
||||
func MiddlewareWithLogger(accessLogger *zap.Logger) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// 记录请求开始时间
|
||||
startTime := time.Now()
|
||||
@@ -136,10 +179,14 @@ func Middleware() fiber.Handler {
|
||||
c.SetUserContext(ctx)
|
||||
|
||||
// 获取请求 body(在 c.Next() 之前读取)
|
||||
requestBody := sanitizeBody(c.Body())
|
||||
policy := policyForPath(c.Path())
|
||||
requestBody := sanitizeWithPolicy(c.Body(), c.Get("Content-Type"), policy)
|
||||
|
||||
// 获取 query 参数
|
||||
queryParams := sanitizeQuery(string(c.Request().URI().QueryString()))
|
||||
if policy.Sensitive && queryParams.Size > 0 {
|
||||
queryParams = summarize([]byte(c.Request().URI().QueryString()))
|
||||
}
|
||||
|
||||
// 处理请求
|
||||
err := c.Next()
|
||||
@@ -162,22 +209,29 @@ func Middleware() fiber.Handler {
|
||||
}
|
||||
|
||||
// 获取响应 body
|
||||
responseBody := truncateBody(c.Response().Body(), MaxBodyLogSize)
|
||||
responseBody := sanitizeWithPolicy(c.Response().Body(), string(c.Response().Header.ContentType()), policy)
|
||||
|
||||
// 记录访问日志
|
||||
accessLogger := GetAccessLogger()
|
||||
accessLogger.Info("",
|
||||
zap.String("method", c.Method()),
|
||||
zap.String("path", c.Path()),
|
||||
zap.String("query", queryParams),
|
||||
zap.String("body_policy", policy.Name),
|
||||
zap.String("query", queryParams.Content),
|
||||
zap.Bool("query_truncated", queryParams.Truncated),
|
||||
zap.Int("status", c.Response().StatusCode()),
|
||||
zap.Float64("duration_ms", float64(duration.Microseconds())/1000.0),
|
||||
zap.String("request_id", requestID),
|
||||
zap.String("ip", c.IP()),
|
||||
zap.String("user_agent", c.Get("User-Agent")),
|
||||
zap.Uint("user_id", userID),
|
||||
zap.String("request_body", requestBody),
|
||||
zap.String("response_body", responseBody),
|
||||
zap.String("request_body", requestBody.Content),
|
||||
zap.Int("request_body_size", requestBody.Size),
|
||||
zap.String("request_body_sha256", requestBody.SHA256),
|
||||
zap.Bool("request_body_truncated", requestBody.Truncated),
|
||||
zap.String("response_body", responseBody.Content),
|
||||
zap.Int("response_body_size", responseBody.Size),
|
||||
zap.String("response_body_sha256", responseBody.SHA256),
|
||||
zap.Bool("response_body_truncated", responseBody.Truncated),
|
||||
)
|
||||
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user