Merge pull request #4485 from Sub2API-Devs/dev

feat(security-audit): 新增 OpenAI 兼容提示词审计能力与安全审计控制台
This commit is contained in:
Wesley Liddick
2026-07-17 16:15:26 +08:00
committed by GitHub
112 changed files with 18851 additions and 71 deletions
+93 -14
View File
@@ -21,6 +21,7 @@ const (
auditCtxKeyActorID = "audit_actor_id"
auditCtxKeyActorEmail = "audit_actor_email"
auditCtxKeySkip = "audit_skip"
auditCtxKeyExtra = "audit_extra"
// ContextKeyAuthEmail 认证中间件写入的用户邮箱(审计用)。
ContextKeyAuthEmail = "auth_email"
// ContextKeySessionID 认证中间件写入的会话 ID(refresh token family)。
@@ -47,6 +48,65 @@ func SkipAudit(c *gin.Context) {
c.Set(auditCtxKeySkip, true)
}
// auditExtraAllowedKeys is deliberately narrow: handlers may only attach
// scalar, non-secret operation summaries. Request bodies and arbitrary maps
// are never accepted through this channel.
var auditExtraAllowedKeys = map[string]struct{}{
"result": {}, "error_code": {}, "enabled": {}, "blocking_enabled": {},
"config_version": {}, "endpoint_count": {}, "scanner_count": {},
"all_groups": {}, "group_count": {}, "guard_endpoint_id": {},
"http_status": {}, "latency_ms": {}, "token_applied": {}, "retryable": {},
"event_id": {}, "requested_count": {}, "deleted_events": {}, "deleted_jobs": {},
"matched_count": {}, "snapshot_max_id": {}, "filter_hash": {}, "confirm": {},
}
// SetAuditExtra adds allowlisted, scalar details to the current audit entry.
// It is safe to call more than once; later values replace earlier ones.
func SetAuditExtra(c *gin.Context, fields map[string]any) {
if c == nil || len(fields) == 0 {
return
}
current := map[string]any{}
if value, ok := c.Get(auditCtxKeyExtra); ok {
if existing, ok := value.(map[string]any); ok {
for key, item := range existing {
current[key] = item
}
}
}
for key, value := range fields {
if _, ok := auditExtraAllowedKeys[key]; !ok || !isAuditExtraScalar(value) {
continue
}
if text, ok := value.(string); ok {
value = truncateAuditExtraString(text, 128)
}
current[key] = value
}
c.Set(auditCtxKeyExtra, current)
}
func isAuditExtraScalar(value any) bool {
switch value.(type) {
case string, bool,
int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
float32, float64:
return true
default:
return false
}
}
func truncateAuditExtraString(value string, limit int) string {
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
// auditSensitiveReads 需要审计的敏感 GET 读取(method+FullPath → 动作名)。
var auditSensitiveReads = map[string]string{
"GET /api/v1/admin/accounts/data": "admin.accounts.export",
@@ -62,25 +122,37 @@ var auditSensitiveReads = map[string]string{
// auditActionOverrides 变更类请求的动作名精确映射(未命中时自动推导)。
var auditActionOverrides = map[string]string{
"POST /api/v1/auth/login": service.AuditActionLogin,
"POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA,
"POST /api/v1/auth/register": service.AuditActionRegister,
"POST /api/v1/auth/refresh": service.AuditActionTokenRefresh,
"POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify,
"POST /api/v1/admin/audit-logs/clear": service.AuditActionAuditLogClear,
"POST /api/v1/admin/accounts/data": "admin.accounts.import",
"POST /api/v1/admin/backups": "admin.backups.create",
"POST /api/v1/admin/backups/:id/restore": "admin.backups.restore",
"DELETE /api/v1/admin/backups/:id": "admin.backups.delete",
"PUT /api/v1/admin/backups/s3-config": "admin.backups.s3_config.update",
"POST /api/v1/admin/settings/admin-api-key/regenerate": "admin.admin_api_key.regenerate",
"DELETE /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.delete",
"POST /api/v1/auth/login": service.AuditActionLogin,
"POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA,
"POST /api/v1/auth/register": service.AuditActionRegister,
"POST /api/v1/auth/refresh": service.AuditActionTokenRefresh,
"POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify,
"POST /api/v1/admin/audit-logs/clear": service.AuditActionAuditLogClear,
"POST /api/v1/admin/accounts/data": "admin.accounts.import",
"POST /api/v1/admin/backups": "admin.backups.create",
"POST /api/v1/admin/backups/:id/restore": "admin.backups.restore",
"DELETE /api/v1/admin/backups/:id": "admin.backups.delete",
"PUT /api/v1/admin/backups/s3-config": "admin.backups.s3_config.update",
"POST /api/v1/admin/settings/admin-api-key/regenerate": "admin.admin_api_key.regenerate",
"DELETE /api/v1/admin/settings/admin-api-key": "admin.admin_api_key.delete",
"PUT /api/v1/admin/prompt-audit/config": "admin.prompt_audit.config.update",
"POST /api/v1/admin/prompt-audit/endpoints/probe": "admin.prompt_audit.endpoint.probe",
"DELETE /api/v1/admin/prompt-audit/events/:id": "admin.prompt_audit.event.delete",
"POST /api/v1/admin/prompt-audit/events/batch-delete": "admin.prompt_audit.events.batch_delete",
"POST /api/v1/admin/prompt-audit/events/delete-preview": "admin.prompt_audit.events.delete_preview",
"POST /api/v1/admin/prompt-audit/events/delete-by-filter": "admin.prompt_audit.events.filter_delete",
}
// auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。
// 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。
var auditBodyOmittedRoutes = map[string]struct{}{
"POST /api/v1/admin/accounts/import/codex-session": {},
"POST /api/v1/admin/accounts/import/codex-session": {},
"PUT /api/v1/admin/prompt-audit/config": {},
"POST /api/v1/admin/prompt-audit/endpoints/probe": {},
"DELETE /api/v1/admin/prompt-audit/events/:id": {},
"POST /api/v1/admin/prompt-audit/events/batch-delete": {},
"POST /api/v1/admin/prompt-audit/events/delete-preview": {},
"POST /api/v1/admin/prompt-audit/events/delete-by-filter": {},
}
// NewAuditLogMiddleware 创建审计中间件。
@@ -195,6 +267,13 @@ func NewAuditLogMiddleware(auditService *service.AuditLogService) AuditLogMiddle
entry.CredentialMasked = MaskedRequestCredential(c)
extra := map[string]any{}
if value, ok := c.Get(auditCtxKeyExtra); ok {
if details, ok := value.(map[string]any); ok {
for key, item := range details {
extra[key] = item
}
}
}
if len(c.Params) > 0 {
params := make(map[string]string, len(c.Params))
for _, p := range c.Params {
@@ -1,6 +1,18 @@
package middleware
import "testing"
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestDeriveAuditAction(t *testing.T) {
cases := []struct {
@@ -20,3 +32,116 @@ func TestDeriveAuditAction(t *testing.T) {
}
}
}
type auditCaptureRepository struct {
mu sync.Mutex
logs []*service.AuditLog
}
func (r *auditCaptureRepository) BatchInsert(_ context.Context, logs []*service.AuditLog) (int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.logs = append(r.logs, logs...)
return int64(len(logs)), nil
}
func (r *auditCaptureRepository) Insert(_ context.Context, log *service.AuditLog) error {
r.mu.Lock()
defer r.mu.Unlock()
r.logs = append(r.logs, log)
return nil
}
func (r *auditCaptureRepository) List(context.Context, *service.AuditLogFilter) (*service.AuditLogList, error) {
return &service.AuditLogList{}, nil
}
func (r *auditCaptureRepository) GetByID(context.Context, int64) (*service.AuditLog, error) {
return nil, service.ErrAuditLogNotFound
}
func (r *auditCaptureRepository) Count(context.Context) (int64, error) { return 0, nil }
func (r *auditCaptureRepository) TruncateAll(context.Context) error { return nil }
func (r *auditCaptureRepository) DeleteBefore(context.Context, time.Time, int) (int64, error) {
return 0, nil
}
func TestPromptAuditAdminOperationsUseOmittedBodiesAndAllowlistedDetails(t *testing.T) {
gin.SetMode(gin.TestMode)
repository := &auditCaptureRepository{}
auditService := service.NewAuditLogService(repository, nil)
auditService.Start()
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(ContextKeyUser), AuthSubject{UserID: 77})
c.Set(string(ContextKeyUserRole), "admin")
c.Next()
})
router.Use(gin.HandlerFunc(NewAuditLogMiddleware(auditService)))
router.PUT("/api/v1/admin/prompt-audit/config", func(c *gin.Context) {
SetAuditExtra(c, map[string]any{
"result": "failed", "error_code": "prompt_audit_config_conflict", "config_version": int64(9),
"token": "audit-canary-secret", "raw_prompt": "audit-canary-prompt", "nested": map[string]any{"unsafe": true},
})
c.JSON(http.StatusConflict, gin.H{"ok": false})
})
router.POST("/api/v1/admin/prompt-audit/endpoints/probe", func(c *gin.Context) {
SetAuditExtra(c, map[string]any{
"result": "success", "guard_endpoint_id": "guard-1", "http_status": 200,
"latency_ms": 12, "token_applied": true,
})
c.JSON(http.StatusOK, gin.H{"ok": true})
})
for _, request := range []*http.Request{
httptest.NewRequest(http.MethodPut, "/api/v1/admin/prompt-audit/config", bytes.NewBufferString(`{"expected_config_version":8,"token":"audit-canary-secret"}`)),
httptest.NewRequest(http.MethodPost, "/api/v1/admin/prompt-audit/endpoints/probe", bytes.NewBufferString(`{"endpoint":{"token":"audit-canary-secret"}}`)),
} {
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
}
auditService.Stop()
repository.mu.Lock()
logs := append([]*service.AuditLog(nil), repository.logs...)
repository.mu.Unlock()
require.Len(t, logs, 2)
byAction := make(map[string]*service.AuditLog, len(logs))
for _, entry := range logs {
byAction[entry.Action] = entry
require.Equal(t, "<credential-bearing body omitted>", entry.RequestBody)
require.NotContains(t, entry.RequestBody, "audit-canary")
require.NotContains(t, entry.Extra, "token")
require.NotContains(t, entry.Extra, "raw_prompt")
require.NotContains(t, entry.Extra, "nested")
}
config := byAction["admin.prompt_audit.config.update"]
require.NotNil(t, config)
require.Equal(t, http.StatusConflict, config.StatusCode)
require.Equal(t, "failed", config.Extra["result"])
require.Equal(t, "prompt_audit_config_conflict", config.Extra["error_code"])
require.EqualValues(t, 9, config.Extra["config_version"])
probe := byAction["admin.prompt_audit.endpoint.probe"]
require.NotNil(t, probe)
require.Equal(t, http.StatusOK, probe.StatusCode)
require.Equal(t, "success", probe.Extra["result"])
require.Equal(t, "guard-1", probe.Extra["guard_endpoint_id"])
require.Equal(t, true, probe.Extra["token_applied"])
}
func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing.T) {
expected := map[string]string{
"PUT /api/v1/admin/prompt-audit/config": "admin.prompt_audit.config.update",
"POST /api/v1/admin/prompt-audit/endpoints/probe": "admin.prompt_audit.endpoint.probe",
"DELETE /api/v1/admin/prompt-audit/events/:id": "admin.prompt_audit.event.delete",
"POST /api/v1/admin/prompt-audit/events/batch-delete": "admin.prompt_audit.events.batch_delete",
"POST /api/v1/admin/prompt-audit/events/delete-preview": "admin.prompt_audit.events.delete_preview",
"POST /api/v1/admin/prompt-audit/events/delete-by-filter": "admin.prompt_audit.events.filter_delete",
}
for route, action := range expected {
require.Equal(t, action, auditActionOverrides[route])
_, omitted := auditBodyOmittedRoutes[route]
require.Truef(t, omitted, "%s must not persist its credential or confirmation-bearing body", route)
}
}
+19
View File
@@ -108,6 +108,9 @@ func RegisterAdminRoutes(
// 风控中心
registerContentModerationRoutes(admin, h)
// 独立提示词输入审计
registerPromptAuditRoutes(admin, h)
// 邀请返利(专属用户管理)
registerAffiliateRoutes(admin, h)
@@ -116,6 +119,22 @@ func RegisterAdminRoutes(
}
}
func registerPromptAuditRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
promptAudit := admin.Group("/prompt-audit")
{
promptAudit.GET("/config", h.Admin.PromptAudit.GetConfig)
promptAudit.PUT("/config", h.Admin.PromptAudit.UpdateConfig)
promptAudit.POST("/endpoints/probe", h.Admin.PromptAudit.ProbeEndpoint)
promptAudit.GET("/runtime", h.Admin.PromptAudit.GetRuntime)
promptAudit.GET("/events", h.Admin.PromptAudit.ListEvents)
promptAudit.GET("/events/:id", h.Admin.PromptAudit.GetEvent)
promptAudit.DELETE("/events/:id", h.Admin.PromptAudit.DeleteEvent)
promptAudit.POST("/events/batch-delete", h.Admin.PromptAudit.BatchDelete)
promptAudit.POST("/events/delete-preview", h.Admin.PromptAudit.DeletePreview)
promptAudit.POST("/events/delete-by-filter", h.Admin.PromptAudit.DeleteByFilter)
}
}
func registerAuditLogRoutes(admin *gin.RouterGroup, h *handler.Handlers, _ middleware.StepUpAuthMiddleware) {
auditLogs := admin.Group("/audit-logs")
{
@@ -0,0 +1,136 @@
package routes
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) {
routeSource, err := os.ReadFile("gateway.go")
require.NoError(t, err)
pattern := regexp.MustCompile(`(?:gateway|gemini|r|codexDirect|antigravityV1|antigravityV1Beta)\.POST\("([^"]+)"`)
matches := pattern.FindAllStringSubmatch(string(routeSource), -1)
actual := map[string]struct{}{}
for _, match := range matches {
actual[match[1]] = struct{}{}
}
audited := map[string][]string{
"/messages": {"gateway_handler.go", "openai_gateway_handler.go"},
"/responses": {"gateway_handler_responses.go", "openai_gateway_handler.go"},
"/responses/*subpath": {"gateway_handler_responses.go", "openai_gateway_handler.go"},
"/chat/completions": {"gateway_handler_chat_completions.go", "openai_chat_completions.go"},
"/embeddings": {"openai_embeddings.go"},
"/alpha/search": {"openai_alpha_search.go"},
"/images/generations": {"openai_images.go", "grok_media.go"},
"/images/edits": {"openai_images.go", "grok_media.go"},
"/images/generations/async": {"image_task_handler.go"},
"/images/edits/async": {"image_task_handler.go"},
"/images/batches": {"batch_image_handler.go"},
"/videos/generations": {"grok_media.go"},
"/videos/edits": {"grok_media.go"},
"/videos/extensions": {"grok_media.go"},
"/models/*modelAction": {"gemini_v1beta_handler.go"},
}
excluded := map[string]string{
"/messages/count_tokens": "tokenization only; it does not execute a model request",
"/images/batches/:id/cancel": "control-plane cancellation with no user prompt",
}
unclassified := make([]string, 0)
for route := range actual {
if _, ok := audited[route]; ok {
continue
}
if _, ok := excluded[route]; ok {
continue
}
unclassified = append(unclassified, route)
}
sort.Strings(unclassified)
require.Empty(t, unclassified, "new gateway POST routes must be audited or explicitly classified with a no-prompt reason")
for route, files := range audited {
_, exists := actual[route]
require.Truef(t, exists, "stale prompt-audit route manifest entry %s", route)
for _, filename := range files {
source, readErr := os.ReadFile(filepath.Join("..", "..", "handler", filename))
require.NoError(t, readErr)
require.Containsf(t, string(source), "checkSecurityAudit", "%s route handler %s bypasses Coordinator", route, filename)
}
}
for route, reason := range excluded {
require.NotEmpty(t, strings.TrimSpace(reason))
_, exists := actual[route]
require.Truef(t, exists, "stale excluded route %s", route)
}
}
func TestResponsesWebSocketHasFirstAndSubsequentTurnPromptGates(t *testing.T) {
routeSource, err := os.ReadFile("gateway.go")
require.NoError(t, err)
require.GreaterOrEqual(t, strings.Count(string(routeSource), `.GET("/responses"`), 2)
handlerSource, err := os.ReadFile(filepath.Join("..", "..", "handler", "openai_gateway_handler.go"))
require.NoError(t, err)
require.Contains(t, string(handlerSource), `checkSecurityAuditStage`)
require.Contains(t, string(handlerSource), `"first_turn"`)
require.Contains(t, string(handlerSource), `"subsequent_turn"`)
wsStart := strings.Index(string(handlerSource), `func (h *OpenAIGatewayHandler) ResponsesWebSocket`)
require.NotEqual(t, -1, wsStart)
wsSource := string(handlerSource)[wsStart:]
require.Less(t,
strings.Index(wsSource, `"first_turn"`),
strings.Index(wsSource, `TryAcquireUserSlotForAPIKey`),
"the first response.create gate must precede per-request user/account slots",
)
}
func TestPromptAuditAdminRoutesRejectUnauthenticatedAndNonAdminRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
handlers := &handler.Handlers{Admin: &handler.AdminHandlers{
PromptAudit: securityaudit.NewPromptAdminHandler(nil),
}}
adminAuth := servermiddleware.AdminAuthMiddleware(func(c *gin.Context) {
if c.GetHeader("Authorization") == "" {
servermiddleware.AbortWithError(c, http.StatusUnauthorized, "UNAUTHORIZED", "Authorization required")
return
}
servermiddleware.AbortWithError(c, http.StatusForbidden, "FORBIDDEN", "Admin access required")
})
auditLog := servermiddleware.AuditLogMiddleware(func(c *gin.Context) { c.Next() })
stepUp := servermiddleware.StepUpAuthMiddleware(func(c *gin.Context) { c.Next() })
RegisterAdminRoutes(router.Group("/api/v1"), handlers, adminAuth, auditLog, stepUp, nil)
for _, tc := range []struct {
name string
auth string
wantStatus int
}{
{name: "unauthenticated", wantStatus: http.StatusUnauthorized},
{name: "non-admin", auth: "Bearer user-token", wantStatus: http.StatusForbidden},
} {
t.Run(tc.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/prompt-audit/config", nil)
if tc.auth != "" {
request.Header.Set("Authorization", tc.auth)
}
router.ServeHTTP(recorder, request)
require.Equal(t, tc.wantStatus, recorder.Code)
})
}
}