| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258 |
- package service
- import (
- "context"
- "encoding/json"
- "strconv"
- "strings"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/util/logredact"
- )
- // ErrAuditLogNotFound 审计日志不存在。
- var ErrAuditLogNotFound = infraerrors.NotFound("AUDIT_LOG_NOT_FOUND", "audit log not found")
- // 审计日志相关常量。
- const (
- // AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。
- AuditAuthMethodJWT = "jwt"
- AuditAuthMethodAdminAPIKey = "admin_api_key"
- AuditAuthMethodPasskey = "passkey"
- // auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。
- auditRequestBodyMaxBytes = 16 * 1024
- // AuditRequestBodyCaptureLimit 请求体参与脱敏解析的原始大小上限(字节)。
- // 审计中间件按此上限截断读取,超出的请求体仅记录占位符不解析。
- AuditRequestBodyCaptureLimit = 256 * 1024
- )
- // 内置审计动作名(认证/安全事件与特殊操作使用固定值,普通请求由路由自动推导)。
- const (
- AuditActionLogin = "auth.login"
- AuditActionLogin2FA = "auth.login.2fa"
- AuditActionRegister = "auth.register"
- AuditActionTokenRefresh = "auth.token.refresh"
- AuditActionSessionBindingMismatch = "auth.session_binding.mismatch"
- AuditActionStepUpVerify = "auth.step_up.verify"
- AuditActionAuditLogClear = "admin.audit_log.clear"
- )
- // AuditLog 一条管理面操作审计记录。
- type AuditLog struct {
- ID int64 `json:"id"`
- CreatedAt time.Time `json:"created_at"`
- ActorUserID *int64 `json:"actor_user_id,omitempty"`
- ActorEmail string `json:"actor_email"`
- ActorRole string `json:"actor_role"`
- AuthMethod string `json:"auth_method"`
- CredentialMasked string `json:"credential_masked"`
- Action string `json:"action"`
- Method string `json:"method"`
- Path string `json:"path"`
- RequestID string `json:"request_id"`
- ClientIP string `json:"client_ip"`
- UserAgent string `json:"user_agent"`
- RequestBody string `json:"request_body,omitempty"`
- StatusCode int `json:"status_code"`
- LatencyMs int64 `json:"latency_ms"`
- Extra map[string]any `json:"extra,omitempty"`
- }
- // AuditLogFilter 审计日志列表查询条件。
- type AuditLogFilter struct {
- Page int
- PageSize int
- StartTime *time.Time
- EndTime *time.Time
- ActorUserID *int64
- ActorEmail string
- AuthMethod string
- Action string
- Method string
- ClientIP string
- // Success: nil 全部;true 仅 2xx/3xx;false 仅 >=400。
- Success *bool
- // Query 对 path / action / actor_email 做模糊匹配。
- Query string
- }
- // AuditLogList 分页结果。
- type AuditLogList struct {
- Logs []*AuditLog
- Total int
- Page int
- PageSize int
- }
- // AuditLogRepository 审计日志持久化端口。
- // 注意:接口刻意不提供单条删除能力——审计日志只允许追加与全量清空。
- type AuditLogRepository interface {
- BatchInsert(ctx context.Context, logs []*AuditLog) (int64, error)
- // Insert 同步写入单条(用于清空留痕等必须落库的记录)。
- Insert(ctx context.Context, log *AuditLog) error
- List(ctx context.Context, filter *AuditLogFilter) (*AuditLogList, error)
- GetByID(ctx context.Context, id int64) (*AuditLog, error)
- Count(ctx context.Context) (int64, error)
- // TruncateAll 全量清空(TRUNCATE),返回前需调用方自行 Count 记录行数。
- TruncateAll(ctx context.Context) error
- // DeleteBefore 按保留期批量删除,返回本批删除行数(幂等,可多实例并发)。
- DeleteBefore(ctx context.Context, cutoff time.Time, batchSize int) (int64, error)
- }
- // auditNormalizeBodyKey 归一化键名:小写并去除分隔符,
- // 使 private_key / privateKey / privatekey / api-v3-key 等写法共享同一判定,
- // 避免子串清单假设 snake_case 而漏掉支付渠道等无分隔符风格的密钥字段。
- func auditNormalizeBodyKey(key string) string {
- var b strings.Builder
- b.Grow(len(key))
- for _, r := range strings.ToLower(strings.TrimSpace(key)) {
- switch r {
- case '_', '-', '.', ' ':
- continue
- default:
- _, _ = b.WriteRune(r)
- }
- }
- return b.String()
- }
- // auditBodySensitiveExactKeys 请求体脱敏的精确匹配键(归一化后)。
- // 除内置清单外,程序化并入两份权威敏感表以防清单漂移:
- // - SensitiveCredentialKeys:账号 credentials 的敏感子键(session_key / service_account_json 等)
- // - providerSensitiveConfigFields:支付渠道密钥字段(pkey / privatekey / apiv3key 等)
- var auditBodySensitiveExactKeys = func() map[string]struct{} {
- builtin := []string{
- "code", "codes", "pin", "cvv",
- "authorization", "cookie", "x-api-key",
- "key",
- // 字符串值内嵌完整凭证的字段:
- // proxy_key 为 protocol|host|port|username|password 拼接,
- // custom_key 为用户自设的平台 API Key 明文,
- // session 为 Ollama Cloud 用量的浏览器会话 Cookie 明文。
- "proxy_key", "custom_key", "session",
- }
- set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16)
- for _, k := range builtin {
- set[auditNormalizeBodyKey(k)] = struct{}{}
- }
- for _, k := range SensitiveCredentialKeys {
- set[auditNormalizeBodyKey(k)] = struct{}{}
- }
- for _, fields := range providerSensitiveConfigFields {
- for k := range fields {
- set[auditNormalizeBodyKey(k)] = struct{}{}
- }
- }
- return set
- }()
- // auditBodySensitiveSubstrings 请求体脱敏的包含匹配子串(对归一化后的键名比对)。
- // 命中任一子串即整体擦除该键的值(例如 new_password / secret_access_key / temp_token)。
- var auditBodySensitiveSubstrings = []string{
- "password", "passwd", "secret", "token",
- "apikey", "accesskey", "privatekey",
- "otp", "credentialvalue",
- "sessionkey", "serviceaccount",
- }
- func isAuditSensitiveBodyKey(key string) bool {
- k := auditNormalizeBodyKey(key)
- if _, ok := auditBodySensitiveExactKeys[k]; ok {
- return true
- }
- for _, sub := range auditBodySensitiveSubstrings {
- if strings.Contains(k, sub) {
- return true
- }
- }
- return false
- }
- const auditRedactedPlaceholder = "***"
- // RedactAuditBody 对请求体做审计入库前的脱敏:
- // - JSON:递归擦除敏感键的值(保留结构,base_url 等非敏感字段可见以便追责)
- // - 非 JSON:返回占位说明
- // - 超长:截断并附截断标记
- func RedactAuditBody(raw []byte, contentType string) string {
- if len(raw) == 0 {
- return ""
- }
- if len(raw) > AuditRequestBodyCaptureLimit {
- // raw 可能已被中间件按上限截断,实际请求体只会更大,不报具体字节数。
- return "<body omitted: exceeds " + strconv.Itoa(AuditRequestBodyCaptureLimit) + " bytes>"
- }
- ct := strings.ToLower(contentType)
- if !strings.Contains(ct, "json") || !json.Valid(raw) {
- // 表单等非 JSON 内容走文本兜底脱敏后仍可能含敏感信息,直接不入库。
- return "<non-json body omitted: " + strconv.Itoa(len(raw)) + " bytes, content-type=" + strings.TrimSpace(contentType) + ">"
- }
- var value any
- if err := json.Unmarshal(raw, &value); err != nil {
- return "<unparsable body omitted>"
- }
- redacted := redactAuditValue(value, 0)
- encoded, err := json.Marshal(redacted)
- if err != nil {
- return "<redacted>"
- }
- out := string(encoded)
- if len(out) > auditRequestBodyMaxBytes {
- out = out[:auditRequestBodyMaxBytes] + "...<truncated>"
- }
- return out
- }
- const auditRedactMaxDepth = 24
- func redactAuditValue(value any, depth int) any {
- if depth > auditRedactMaxDepth {
- return "<depth limit exceeded>"
- }
- switch v := value.(type) {
- case map[string]any:
- out := make(map[string]any, len(v))
- for k, item := range v {
- if isAuditSensitiveBodyKey(k) {
- out[k] = auditRedactedPlaceholder
- continue
- }
- out[k] = redactAuditValue(item, depth+1)
- }
- return out
- case []any:
- out := make([]any, len(v))
- for i, item := range v {
- out[i] = redactAuditValue(item, depth+1)
- }
- return out
- default:
- return value
- }
- }
- // MaskAuditCredential 对请求头中的凭证做首尾保留掩码:
- // 保留前 6 位与后 4 位,中间以 **** 表示;过短的凭证整体掩码。
- func MaskAuditCredential(credential string) string {
- credential = strings.TrimSpace(credential)
- if credential == "" {
- return ""
- }
- if len(credential) <= 14 {
- return "****"
- }
- return credential[:6] + "****" + credential[len(credential)-4:]
- }
- // RedactAuditQuery 对 URL query 做轻量脱敏后返回。
- func RedactAuditQuery(rawQuery string) string {
- rawQuery = strings.TrimSpace(rawQuery)
- if rawQuery == "" {
- return ""
- }
- return logredact.RedactText(rawQuery, "api_key", "apikey", "token", "secret", "key")
- }
|