All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
- 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史 - H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列 - 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案 - 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容 - 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏 - 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过
291 lines
8.6 KiB
Go
291 lines
8.6 KiB
Go
package logger
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
||
"github.com/bytedance/sonic"
|
||
"github.com/gofiber/fiber/v2"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
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, bool) {
|
||
if len(body) == 0 {
|
||
return "", false
|
||
}
|
||
|
||
if len(body) <= maxSize {
|
||
return string(body), false
|
||
}
|
||
|
||
return string(body[:maxSize]), true
|
||
}
|
||
|
||
// phoneFieldNames 是访问日志中属于手机号类的字段名集合。
|
||
// 手机号不在 sanitizer 的 forbidden 字段清单内,仅靠字段名通用清理无法脱敏;
|
||
// 这里单独识别并输出前 3 位 + **** + 后 4 位,禁止完整号码进入 query、请求正文或响应正文。
|
||
// 只覆盖手机号类字段,forbidden 字段仍按原语义标记为已脱敏,两者处理互不影响。
|
||
var phoneFieldNames = map[string]struct{}{
|
||
"phone": {},
|
||
"mobile": {},
|
||
"associated_phones": {},
|
||
}
|
||
|
||
// normalizeLogFieldName 归一化字段名用于敏感字段判断。
|
||
func normalizeLogFieldName(key string) string {
|
||
var builder strings.Builder
|
||
for index, char := range key {
|
||
if unicode.IsUpper(char) && index > 0 {
|
||
builder.WriteByte('_')
|
||
}
|
||
if char == '-' || char == '.' {
|
||
builder.WriteByte('_')
|
||
continue
|
||
}
|
||
builder.WriteRune(unicode.ToLower(char))
|
||
}
|
||
return builder.String()
|
||
}
|
||
|
||
// isPhoneField 判断字段名是否属于手机号类字段(与 forbidden 字段分开处理)。
|
||
func isPhoneField(key string) bool {
|
||
_, ok := phoneFieldNames[normalizeLogFieldName(key)]
|
||
return ok
|
||
}
|
||
|
||
// maskSensitiveValue 按字段名判断并脱敏访问日志中的敏感值
|
||
func maskSensitiveValue(key, value string) string {
|
||
if value == "" {
|
||
return value
|
||
}
|
||
if shouldMaskField(key) {
|
||
return redactedValue
|
||
}
|
||
if isPhoneField(key) {
|
||
return sanitizer.MaskPhone(value)
|
||
}
|
||
return value
|
||
}
|
||
|
||
// sanitizeQuery 脱敏 query 中的密码、签名、nonce、token 等字段
|
||
func sanitizeQuery(rawQuery string) SanitizedContent {
|
||
if rawQuery == "" {
|
||
return SanitizedContent{}
|
||
}
|
||
values, err := url.ParseQuery(rawQuery)
|
||
if err != nil {
|
||
return summarize([]byte(rawQuery))
|
||
}
|
||
for key, items := range values {
|
||
for index, item := range items {
|
||
items[index] = maskSensitiveValue(key, item)
|
||
}
|
||
values[key] = items
|
||
}
|
||
content, truncated := truncateBody([]byte(values.Encode()), MaxBodyLogSize)
|
||
return SanitizedContent{Content: content, Size: len(rawQuery), SHA256: digest([]byte(rawQuery)), Truncated: truncated}
|
||
}
|
||
|
||
// sanitizeBody 按策略脱敏正文后再写入访问日志。
|
||
func sanitizeBody(rawBody []byte, policy BodyPolicy) SanitizedContent {
|
||
if len(rawBody) == 0 {
|
||
return SanitizedContent{}
|
||
}
|
||
if policy == BodyPolicySummary {
|
||
return summarize(rawBody)
|
||
}
|
||
var payload any
|
||
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||
return summarize(rawBody)
|
||
}
|
||
sanitizeJSONValue(payload)
|
||
data, err := sonic.Marshal(payload)
|
||
if err != nil {
|
||
return summarize(rawBody)
|
||
}
|
||
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 对象中的敏感字段
|
||
func sanitizeJSONValue(value any) {
|
||
switch typed := value.(type) {
|
||
case map[string]any:
|
||
for key, item := range typed {
|
||
if masked, ok := item.(string); ok {
|
||
typed[key] = maskSensitiveValue(key, masked)
|
||
continue
|
||
}
|
||
if shouldMaskField(key) {
|
||
typed[key] = redactedValue
|
||
continue
|
||
}
|
||
if isPhoneField(key) {
|
||
// 手机号类数组逐元素脱敏;非字符串元素不保留原值。
|
||
list, ok := item.([]any)
|
||
if !ok {
|
||
if item != nil {
|
||
typed[key] = redactedValue
|
||
}
|
||
continue
|
||
}
|
||
for index, element := range list {
|
||
if text, ok := element.(string); ok {
|
||
list[index] = sanitizer.MaskPhone(text)
|
||
continue
|
||
}
|
||
list[index] = redactedValue
|
||
}
|
||
typed[key] = list
|
||
continue
|
||
}
|
||
sanitizeJSONValue(item)
|
||
}
|
||
case []any:
|
||
for _, item := range typed {
|
||
sanitizeJSONValue(item)
|
||
}
|
||
}
|
||
}
|
||
|
||
// shouldMaskField 判断字段名是否属于访问日志敏感字段
|
||
func shouldMaskField(key string) bool {
|
||
return sanitizer.IsForbiddenField(key)
|
||
}
|
||
|
||
// 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()
|
||
c.Locals(constants.ContextKeyStartTime, startTime)
|
||
|
||
// 注入请求上下文,供 Service 层审计日志复用
|
||
ctx := c.UserContext()
|
||
requestID := ""
|
||
if rid := c.Locals(constants.ContextKeyRequestID); rid != nil {
|
||
if value, ok := rid.(string); ok && value != "" {
|
||
requestID = value
|
||
ctx = context.WithValue(ctx, constants.ContextKeyRequestID, requestID)
|
||
}
|
||
}
|
||
ctx = context.WithValue(ctx, constants.ContextKeyIP, c.IP())
|
||
ctx = context.WithValue(ctx, constants.ContextKeyUserAgent, c.Get("User-Agent"))
|
||
ctx = context.WithValue(ctx, constants.ContextKeyRequestPath, c.Path())
|
||
ctx = context.WithValue(ctx, constants.ContextKeyRequestMethod, c.Method())
|
||
ctx = auditcontext.With(ctx, auditcontext.Context{
|
||
RequestID: requestID, CorrelationID: requestID,
|
||
RequestPath: c.Path(), RequestMethod: c.Method(),
|
||
IPAddress: c.IP(), UserAgent: c.Get("User-Agent"),
|
||
})
|
||
c.SetUserContext(ctx)
|
||
|
||
// 获取请求 body(在 c.Next() 之前读取)
|
||
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()
|
||
|
||
// 计算请求持续时间
|
||
duration := time.Since(startTime)
|
||
|
||
// 获取请求 ID(由 requestid 中间件设置)
|
||
requestID = ""
|
||
if rid := c.Locals(constants.ContextKeyRequestID); rid != nil {
|
||
requestID = rid.(string)
|
||
}
|
||
|
||
// 获取用户 ID(由 auth 中间件设置)
|
||
var userID uint
|
||
if uid := c.Locals(constants.ContextKeyUserID); uid != nil {
|
||
if id, ok := uid.(uint); ok {
|
||
userID = id
|
||
}
|
||
}
|
||
|
||
// 获取响应 body
|
||
responseBody := sanitizeWithPolicy(c.Response().Body(), string(c.Response().Header.ContentType()), policy)
|
||
|
||
// 记录访问日志
|
||
accessLogger.Info("",
|
||
zap.String("method", c.Method()),
|
||
zap.String("path", c.Path()),
|
||
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.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
|
||
}
|
||
}
|