Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
259 lines
8.8 KiB
Go
259 lines
8.8 KiB
Go
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")
|
||
}
|