diff --git a/backend/internal/handler/security_audit_helper.go b/backend/internal/handler/security_audit_helper.go index 03b6e1de5..eab6ab340 100644 --- a/backend/internal/handler/security_audit_helper.go +++ b/backend/internal/handler/security_audit_helper.go @@ -13,6 +13,18 @@ import ( const securityAuditCompletedContextKey = "sub2api.security_audit.completed" +// cachesSecurityAuditCompletion reports whether a successful audit may be +// reused for the rest of the gin request. WebSocket turns share one Context +// across many response.create frames and must be audited independently. +func cachesSecurityAuditCompletion(stage string) bool { + switch strings.TrimSpace(stage) { + case "", "http": + return true + default: + return false + } +} + func (h *GatewayHandler) checkSecurityAudit(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol, model string, body []byte) *securityaudit.Decision { if h == nil { return nil @@ -38,8 +50,11 @@ func runSecurityAudit(c *gin.Context, reqLog *zap.Logger, coordinator *securitya if c == nil || c.Request == nil { return nil } - if completed, exists := c.Get(securityAuditCompletedContextKey); exists && completed == true { - return nil + cacheCompletion := cachesSecurityAuditCompletion(stage) + if cacheCompletion { + if completed, exists := c.Get(securityAuditCompletedContextKey); exists && completed == true { + return nil + } } if coordinator == nil { legacyDecision := runContentModeration(c, reqLog, legacy, apiKey, subject, protocol, model, body) @@ -55,7 +70,7 @@ func runSecurityAudit(c *gin.Context, reqLog *zap.Logger, coordinator *securitya if legacyDecision.Blocked { decision.Kind, decision.HTTPStatus, decision.ErrorCode, decision.ClientMessage, decision.AllowNextStage = securityaudit.DecisionBlock, contentModerationStatus(legacyDecision), "content_policy_violation", legacyDecision.Message, false } - if decision.AllowNextStage { + if decision.AllowNextStage && cacheCompletion { c.Set(securityAuditCompletedContextKey, true) } return &decision @@ -70,7 +85,7 @@ func runSecurityAudit(c *gin.Context, reqLog *zap.Logger, coordinator *securitya zap.Int("body_bytes", len(body))) } decision := coordinator.Check(c.Request.Context(), request) - if decision.AllowNextStage { + if decision.AllowNextStage && cacheCompletion { c.Set(securityAuditCompletedContextKey, true) } if reqLog != nil { diff --git a/backend/internal/handler/security_audit_helper_test.go b/backend/internal/handler/security_audit_helper_test.go new file mode 100644 index 000000000..fa3e0eeca --- /dev/null +++ b/backend/internal/handler/security_audit_helper_test.go @@ -0,0 +1,63 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/securityaudit" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCachesSecurityAuditCompletionSkipsWebSocketStages(t *testing.T) { + require.True(t, cachesSecurityAuditCompletion("http")) + require.True(t, cachesSecurityAuditCompletion("")) + require.False(t, cachesSecurityAuditCompletion("first_turn")) + require.False(t, cachesSecurityAuditCompletion("subsequent_turn")) +} + +func TestRunSecurityAuditDoesNotSkipSubsequentWebSocketTurns(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := &turnCountingEngine{mode: securityaudit.ModeAsync} + coordinator := securityaudit.NewCoordinator(nil, engine) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + subject := middleware2.AuthSubject{UserID: 7, Concurrency: 1} + first := runSecurityAudit(c, nil, coordinator, nil, nil, subject, "openai_responses", "gpt-test", + []byte(`{"type":"response.create","response":{"input":"benign"}}`), "first_turn") + require.NotNil(t, first) + require.True(t, first.AllowNextStage) + require.Equal(t, int64(1), engine.enqueues.Load()) + _, cached := c.Get(securityAuditCompletedContextKey) + require.False(t, cached, "WebSocket stages must not set the HTTP completion cache") + + // Even if an HTTP path previously cached completion on this Context, WS turns + // must still audit every response.create payload. + c.Set(securityAuditCompletedContextKey, true) + + second := runSecurityAudit(c, nil, coordinator, nil, nil, subject, "openai_responses", "gpt-test", + []byte(`{"type":"response.create","response":{"input":"malicious follow-up"}}`), "subsequent_turn") + require.NotNil(t, second) + require.Equal(t, int64(2), engine.enqueues.Load(), "subsequent WebSocket turns must be audited again") +} + +type turnCountingEngine struct { + mode securityaudit.Mode + enqueues atomic.Int64 +} + +func (e *turnCountingEngine) EffectiveMode() securityaudit.Mode { return e.mode } +func (e *turnCountingEngine) Enqueue(context.Context, securityaudit.Request) error { + e.enqueues.Add(1) + return nil +} +func (e *turnCountingEngine) Evaluate(context.Context, securityaudit.Request) (*securityaudit.PromptDecision, error) { + return &securityaudit.PromptDecision{Kind: securityaudit.DecisionAllow, AllowNextStage: true}, nil +} diff --git a/backend/internal/securityaudit/prompt_config.go b/backend/internal/securityaudit/prompt_config.go index ed05e10bc..2f6485ad8 100644 --- a/backend/internal/securityaudit/prompt_config.go +++ b/backend/internal/securityaudit/prompt_config.go @@ -39,6 +39,9 @@ type ConfigStore interface { Shutdown(ctx context.Context) error Active() (ActiveConfig, bool) EffectiveMode() Mode + // BlockingActivationDegraded is true when storage intent requires blocking + // but no usable blocking snapshot is active (cold start or failed reload). + BlockingActivationDegraded() bool Public() PublicConfig Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string) diff --git a/backend/internal/securityaudit/prompt_config_store.go b/backend/internal/securityaudit/prompt_config_store.go index b0781c858..e67f115fe 100644 --- a/backend/internal/securityaudit/prompt_config_store.go +++ b/backend/internal/securityaudit/prompt_config_store.go @@ -129,15 +129,25 @@ func (m *ConfigManager) Active() (ActiveConfig, bool) { return cloneActiveConfig(snapshot.active), true } +func (m *ConfigManager) BlockingActivationDegraded() bool { + if m == nil || !m.expectedBlocking.Load() { + return false + } + active, ok := m.Active() + if !ok { + return true + } + // A still-active weaker snapshot after a failed blocking activation must not + // keep serving allow decisions under the old off/async mode. + return active.EffectiveMode() != ModeBlocking +} + func (m *ConfigManager) EffectiveMode() Mode { + if m != nil && m.BlockingActivationDegraded() { + return ModeBlocking + } active, ok := m.Active() if !ok { - // A cold start without a valid snapshot fails closed only when the last - // decodable storage intent explicitly required blocking. Config version is - // not a mode signal: an async-only config can have any version. - if m != nil && m.expectedBlocking.Load() { - return ModeBlocking - } return ModeOff } return active.EffectiveMode() diff --git a/backend/internal/securityaudit/prompt_config_test.go b/backend/internal/securityaudit/prompt_config_test.go index 1023e96fc..399f5d250 100644 --- a/backend/internal/securityaudit/prompt_config_test.go +++ b/backend/internal/securityaudit/prompt_config_test.go @@ -1,6 +1,7 @@ package securityaudit import ( + "context" "encoding/json" "errors" "testing" @@ -98,17 +99,38 @@ func TestConfigManagerColdStartOnlyFailsClosedForExplicitBlockingIntent(t *testi manager.observeExpectedState(`{"enabled":true,"blocking_enabled":false,"config_version":42}`, true) require.Equal(t, int64(42), manager.expected.Load()) require.Equal(t, ModeOff, manager.EffectiveMode(), "an async config version must not imply blocking") + require.False(t, manager.BlockingActivationDegraded()) manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":43}`, false) require.Equal(t, ModeOff, manager.EffectiveMode(), "the global risk-control switch still gates blocking") manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":44}`, true) require.Equal(t, ModeBlocking, manager.EffectiveMode()) + require.True(t, manager.BlockingActivationDegraded()) manager.observeExpectedState(`{"enabled":true`, true) require.Equal(t, ModeBlocking, manager.EffectiveMode(), "undecodable storage must not erase the last known strict intent") } +func TestConfigManagerStaleWeakerSnapshotFailsClosedWhenBlockingExpected(t *testing.T) { + manager := &ConfigManager{} + async := ActiveConfig{RiskControlEnabled: true, Enabled: true, BlockingEnabled: false, ConfigVersion: 1} + manager.snapshot.Store(&activeConfigSnapshot{active: async, storage: DefaultStorageConfig(), loadedAt: fixedClock{}.Now()}) + manager.expected.Store(2) + manager.expectedBlocking.Store(true) + + require.True(t, manager.BlockingActivationDegraded()) + require.Equal(t, ModeBlocking, manager.EffectiveMode()) + + service := &PromptService{config: manager, evaluator: NewGuardEvaluator(nil, nil, nil)} + decision, err := service.Evaluate(context.Background(), Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`)}) + require.Error(t, err) + require.Nil(t, decision) + var guardErr *GuardError + require.ErrorAs(t, err, &guardErr) + require.Equal(t, ErrorCodeUnavailable, guardErr.Code) +} + func TestParseLegacyConfigDefaultsMissingFieldsWithoutEnablingBlocking(t *testing.T) { storage, err := ParseStorageConfig(`{"enabled":false,"config_version":9}`) require.NoError(t, err) diff --git a/backend/internal/securityaudit/prompt_event_repository.go b/backend/internal/securityaudit/prompt_event_repository.go index 3960aead3..aaa74588a 100644 --- a/backend/internal/securityaudit/prompt_event_repository.go +++ b/backend/internal/securityaudit/prompt_event_repository.go @@ -314,7 +314,7 @@ func eventColumns(alias string) string { return fmt.Sprintf(`%[1]s.id,%[1]s.job_id,%[1]s.request_id,%[1]s.user_id,%[1]s.username_snapshot, %[1]s.user_email_snapshot,%[1]s.api_key_id,%[1]s.api_key_name_snapshot,%[1]s.group_id,%[1]s.group_name, %[1]s.provider,%[1]s.endpoint,%[1]s.protocol,%[1]s.model,%[1]s.prompt_hash,%[1]s.redacted_preview, - %[1]s.decision,%[1]s.risk_level,%[1]s.action,%[1]s.categories,%[1]s.matched_scanners, + %[1]s.stage,%[1]s.decision,%[1]s.risk_level,%[1]s.action,%[1]s.categories,%[1]s.matched_scanners, %[1]s.scanner_scores,%[1]s.scanner_evidence,%[1]s.scanner_backend,%[1]s.scanner_version, %[1]s.guard_endpoint_id,%[1]s.policy_id,%[1]s.policy_version,%[1]s.config_version, %[1]s.chunk_total,%[1]s.latency_ms,%[1]s.created_at`, alias) @@ -328,8 +328,8 @@ func scanEvent(row rowScanner) (*Event, error) { &event.Snapshot.UsernameSnapshot, &event.Snapshot.UserEmailSnapshot, &apiKeyID, &event.Snapshot.APIKeyNameSnapshot, &groupID, &event.Snapshot.GroupName, &event.Snapshot.Provider, &event.Snapshot.Endpoint, &event.Snapshot.Protocol, &event.Snapshot.Model, - &event.Snapshot.PromptHash, &event.Snapshot.RedactedPreview, &event.Decision, &event.RiskLevel, - &event.Action, &categories, &matched, &scores, &evidence, &event.ScannerBackend, + &event.Snapshot.PromptHash, &event.Snapshot.RedactedPreview, &event.Snapshot.Stage, &event.Decision, + &event.RiskLevel, &event.Action, &categories, &matched, &scores, &evidence, &event.ScannerBackend, &event.ScannerVersion, &event.GuardEndpointID, &event.PolicyID, &event.PolicyVersion, &event.ConfigVersion, &event.ChunkTotal, &event.LatencyMS, &event.CreatedAt) if err != nil { diff --git a/backend/internal/securityaudit/prompt_outbound_security.go b/backend/internal/securityaudit/prompt_outbound_security.go index 0e0982800..40df98ef0 100644 --- a/backend/internal/securityaudit/prompt_outbound_security.go +++ b/backend/internal/securityaudit/prompt_outbound_security.go @@ -76,7 +76,13 @@ func NormalizeBaseURL(raw string) (string, error) { if isBlockedAddress(addr) { return "", infraerrors.BadRequest("prompt_audit_unsafe_base_url", "审计节点地址不在允许范围") } - allowPrivate = addr.IsPrivate() || addr.IsLoopback() + // Loopback literals remain available for local Guard nodes and tests. + // RFC1918 literals are rejected so an admin session cannot pivot into + // arbitrary private-network services; use a hostname allowlist instead. + if addr.IsPrivate() { + return "", infraerrors.BadRequest("prompt_audit_unsafe_base_url", "审计节点地址不在允许范围") + } + allowPrivate = addr.IsLoopback() } if parsed.Scheme == "http" && !allowPrivate { return "", infraerrors.BadRequest("prompt_audit_https_required", "公网审计节点必须使用 HTTPS") @@ -115,7 +121,7 @@ func NewSecureHTTPClient(endpoint ActiveEndpoint) (*http.Client, error) { host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), ".")) allowPrivate := isExplicitPrivateHost(host) if addr, parseErr := netip.ParseAddr(host); parseErr == nil { - allowPrivate = addr.IsPrivate() || addr.IsLoopback() + allowPrivate = addr.IsLoopback() } resolver := netResolver{resolver: net.DefaultResolver} dialer := &net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second} @@ -180,7 +186,10 @@ func secureDialContext(dialer *net.Dialer, resolver DNSResolver, allowPrivate bo } func isExplicitPrivateHost(host string) bool { - return host == "localhost" || strings.HasSuffix(host, ".localhost") || strings.HasSuffix(host, ".local") + // Only the localhost name family is trusted for private/loopback dials. + // A bare "*.local" suffix is too broad (mDNS/intranet names) and would + // re-open RFC1918 SSRF after literal private IPs were rejected. + return host == "localhost" || strings.HasSuffix(host, ".localhost") } func isBlockedAddress(addr netip.Addr) bool { diff --git a/backend/internal/securityaudit/prompt_outbound_security_test.go b/backend/internal/securityaudit/prompt_outbound_security_test.go index bd687a67e..15e56d0bc 100644 --- a/backend/internal/securityaudit/prompt_outbound_security_test.go +++ b/backend/internal/securityaudit/prompt_outbound_security_test.go @@ -21,7 +21,7 @@ func (r staticResolver) LookupNetIP(context.Context, string, string) ([]netip.Ad } func TestNormalizeBaseURLSecurity(t *testing.T) { - allowed := []string{"https://guard.example.com", "https://guard.example.com/v1", "http://127.0.0.1:8080", "http://10.0.0.8:8080"} + allowed := []string{"https://guard.example.com", "https://guard.example.com/v1", "http://127.0.0.1:8080", "http://localhost:8080"} for _, raw := range allowed { _, err := NormalizeBaseURL(raw) require.NoError(t, err, raw) @@ -31,6 +31,8 @@ func TestNormalizeBaseURLSecurity(t *testing.T) { "https://guard.example.com?q=secret", "https://guard.example.com/#fragment", "http://169.254.169.254", "https://metadata.google.internal", "https://0.0.0.0", "https://224.0.0.1", "https://192.0.2.1", "https://[::]", "https://[fe80::1]", "https://[ff02::1]", "https://[2001:db8::1]", + "http://10.0.0.8:8080", "http://192.168.1.10:8080", "https://172.16.0.5", + "http://internal-admin.local", "http://guard.local:8080", } for _, raw := range blocked { _, err := NormalizeBaseURL(raw) diff --git a/backend/internal/securityaudit/prompt_repository.go b/backend/internal/securityaudit/prompt_repository.go index 2190048b7..2d304c94d 100644 --- a/backend/internal/securityaudit/prompt_repository.go +++ b/backend/internal/securityaudit/prompt_repository.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" ) @@ -316,14 +317,14 @@ func insertJob(ctx context.Context, queryer sqlQueryer, snapshot PromptSnapshot, INSERT INTO prompt_audit_jobs ( request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot, group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview, - prompt_length,message_count,execution_mode,config_version,status,max_attempts,processed_at - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,`+processedExpr+`) + prompt_length,message_count,stage,execution_mode,config_version,status,max_attempts,processed_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,`+processedExpr+`) RETURNING `+jobColumns("prompt_audit_jobs"), snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot, nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName, snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash, - snapshot.RedactedPreview, snapshot.PromptLength, snapshot.MessageCount, string(mode), configVersion, - status, maxAttempts) + snapshot.RedactedPreview, snapshot.PromptLength, snapshot.MessageCount, normalizeStage(snapshot.Stage), + string(mode), configVersion, status, maxAttempts) return scanJob(row) } @@ -339,17 +340,17 @@ func insertEvent(ctx context.Context, queryer sqlQueryer, jobID int64, snapshot row := queryer.QueryRowContext(ctx, ` INSERT INTO prompt_audit_events ( job_id,request_id,user_id,username_snapshot,user_email_snapshot,api_key_id,api_key_name_snapshot, - group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview, + group_id,group_name,provider,endpoint,protocol,model,prompt_hash,redacted_preview,stage, decision,risk_level,action,categories,matched_scanners,scanner_scores,scanner_evidence, scanner_backend,scanner_version,guard_endpoint_id,policy_id,policy_version,config_version,chunk_total,latency_ms - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, - $19::jsonb,$20::jsonb,$21::jsonb,$22::jsonb,$23,$24,$25,$26,$27,$28,$29,$30) + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19, + $20::jsonb,$21::jsonb,$22::jsonb,$23::jsonb,$24,$25,$26,$27,$28,$29,$30,$31) RETURNING `+eventColumns("prompt_audit_events"), jobID, snapshot.RequestID, nullableID(snapshot.UserID), snapshot.UsernameSnapshot, snapshot.UserEmailSnapshot, nullableID(snapshot.APIKeyID), snapshot.APIKeyNameSnapshot, snapshot.GroupID, snapshot.GroupName, snapshot.Provider, snapshot.Endpoint, snapshot.Protocol, snapshot.Model, snapshot.PromptHash, - snapshot.RedactedPreview, string(result.Decision), string(result.RiskLevel), string(result.Action), - categories, matched, scores, evidenceJSON, result.ScannerBackend, result.ScannerVersion, + snapshot.RedactedPreview, normalizeStage(snapshot.Stage), string(result.Decision), string(result.RiskLevel), + string(result.Action), categories, matched, scores, evidenceJSON, result.ScannerBackend, result.ScannerVersion, result.GuardEndpointID, result.PolicyID, result.PolicyVersion, configVersion, result.ChunkTotal, result.LatencyMS) return scanEvent(row) } @@ -364,8 +365,8 @@ func scanJob(row rowScanner) (*Job, error) { &job.ID, &job.Snapshot.RequestID, &userID, &job.Snapshot.UsernameSnapshot, &job.Snapshot.UserEmailSnapshot, &apiKeyID, &job.Snapshot.APIKeyNameSnapshot, &groupID, &job.Snapshot.GroupName, &job.Snapshot.Provider, &job.Snapshot.Endpoint, &job.Snapshot.Protocol, &job.Snapshot.Model, &job.Snapshot.PromptHash, - &job.Snapshot.RedactedPreview, &job.Snapshot.PromptLength, &job.Snapshot.MessageCount, &job.ExecutionMode, - &job.ConfigVersion, &job.Status, &job.Attempts, &job.MaxAttempts, &job.ClaimVersion, + &job.Snapshot.RedactedPreview, &job.Snapshot.PromptLength, &job.Snapshot.MessageCount, &job.Snapshot.Stage, + &job.ExecutionMode, &job.ConfigVersion, &job.Status, &job.Attempts, &job.MaxAttempts, &job.ClaimVersion, &job.NextAttemptAt, &processingStarted, &processed, &job.LastErrorCode, &job.LastErrorMessage, &job.CreatedAt, &job.UpdatedAt, ) @@ -390,12 +391,20 @@ func jobColumns(alias string) string { return fmt.Sprintf(`%[1]s.id,%[1]s.request_id,%[1]s.user_id,%[1]s.username_snapshot,%[1]s.user_email_snapshot, %[1]s.api_key_id,%[1]s.api_key_name_snapshot,%[1]s.group_id,%[1]s.group_name,%[1]s.provider, %[1]s.endpoint,%[1]s.protocol,%[1]s.model,%[1]s.prompt_hash,%[1]s.redacted_preview, - %[1]s.prompt_length,%[1]s.message_count,%[1]s.execution_mode,%[1]s.config_version,%[1]s.status, + %[1]s.prompt_length,%[1]s.message_count,%[1]s.stage,%[1]s.execution_mode,%[1]s.config_version,%[1]s.status, %[1]s.attempts,%[1]s.max_attempts,%[1]s.claim_version,%[1]s.next_attempt_at, %[1]s.processing_started_at,%[1]s.processed_at,%[1]s.last_error_code,%[1]s.last_error_message, %[1]s.created_at,%[1]s.updated_at`, alias) } +func normalizeStage(stage string) string { + stage = strings.TrimSpace(stage) + if stage == "" { + return "http" + } + return stage +} + func requireOneRow(result sql.Result, err error, missing error) error { if err != nil { return err diff --git a/backend/internal/securityaudit/prompt_service.go b/backend/internal/securityaudit/prompt_service.go index 05607a8c7..8402e60d9 100644 --- a/backend/internal/securityaudit/prompt_service.go +++ b/backend/internal/securityaudit/prompt_service.go @@ -143,6 +143,9 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis if s == nil || s.config == nil || s.evaluator == nil { return nil, &GuardError{Code: ErrorCodeUnavailable} } + if s.config.BlockingActivationDegraded() { + return nil, &GuardError{Code: ErrorCodeUnavailable} + } cfg, ok := s.config.Active() if !ok { if s.config.EffectiveMode() == ModeBlocking { @@ -172,10 +175,10 @@ func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, func (s *PromptService) Runtime(ctx context.Context) RuntimeSnapshot { expected, activeVersion, loadedAt, loadError := s.config.RuntimeState() cfg, hasConfig := s.config.Active() - mode := ModeOff + mode := s.EffectiveMode() workerTotal, queueCapacity := 0, 0 if hasConfig { - mode, workerTotal, queueCapacity = cfg.EffectiveMode(), cfg.WorkerCount, cfg.QueueCapacity + workerTotal, queueCapacity = cfg.WorkerCount, cfg.QueueCapacity } runtime := RuntimeSnapshot{ ProcessStatus: "disabled", EffectiveMode: mode, ExpectedConfigVersion: expected, diff --git a/backend/internal/securityaudit/prompt_snapshot.go b/backend/internal/securityaudit/prompt_snapshot.go index b39d92179..13002ded1 100644 --- a/backend/internal/securityaudit/prompt_snapshot.go +++ b/backend/internal/securityaudit/prompt_snapshot.go @@ -42,20 +42,24 @@ func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) { UserEmailSnapshot: req.UserEmail, APIKeyID: req.APIKeyID, APIKeyNameSnapshot: req.APIKeyName, GroupID: cloneInt64Ptr(req.GroupID), GroupName: req.GroupName, Provider: req.Provider, Endpoint: req.Endpoint, Protocol: req.Protocol, Model: req.Model, - PromptHash: hex.EncodeToString(digest[:]), RedactedPreview: BuildPromptPreview(scanText, 480), + PromptHash: hex.EncodeToString(digest[:]), RedactedPreview: BuildPromptPreview(scanText, DefaultPromptPreviewMaxRunes), PromptLength: utf8.RuneCountInString(scanText), MessageCount: len(segments), Stage: stage, ScanText: scanText, }, nil } +// DefaultPromptPreviewMaxRunes caps how much sanitized prompt text may be +// considered before BuildPromptPreview withholds the majority for storage/UI. +const DefaultPromptPreviewMaxRunes = 96 + func extractProtocolSegments(protocol string, document any) []string { root, _ := document.(map[string]any) protocol = strings.ToLower(strings.TrimSpace(protocol)) switch protocol { case "openai_chat_completions", "openai_chat", "chat_completions": - return extractMessages(root["messages"], "user") + return extractChatLikeSegments(root) case "anthropic_messages", "claude_messages", "messages": - return extractMessages(root["messages"], "user") + return append(extractAnthropicSystem(root["system"]), extractMessages(root["messages"], clientInstructionRoles...)...) case "gemini", "gemini_generate_content": return extractGeminiRoot(root) case "openai_responses", "responses", "responses_websocket": @@ -64,21 +68,21 @@ func extractProtocolSegments(protocol string, document any) []string { return nil } if input, exists := root["input"]; exists && input != nil { - return extractResponses(input) + return append(extractInstructions(root["instructions"]), extractResponses(input)...) } if response, ok := root["response"].(map[string]any); ok { - return extractResponses(response["input"]) + return append(extractInstructions(response["instructions"]), extractResponses(response["input"])...) } - return nil + return extractInstructions(root["instructions"]) } - return extractResponses(root["input"]) + return append(extractInstructions(root["instructions"]), extractResponses(root["input"])...) case "openai_images", "grok_media", "media", "images": return extractMediaPrompts(root) default: - if messages := extractMessages(root["messages"], "user"); len(messages) > 0 { - return messages + if segments := extractChatLikeSegments(root); len(segments) > 0 { + return segments } - if responses := extractResponses(root["input"]); len(responses) > 0 { + if responses := append(extractInstructions(root["instructions"]), extractResponses(root["input"])...); len(responses) > 0 { return responses } if gemini := extractGeminiRoot(root); len(gemini) > 0 { @@ -88,15 +92,32 @@ func extractProtocolSegments(protocol string, document any) []string { } } -func extractMessages(value any, wantedRole string) []string { +var clientInstructionRoles = []string{"user", "system", "developer"} + +func extractChatLikeSegments(root map[string]any) []string { + if root == nil { + return nil + } + return extractMessages(root["messages"], clientInstructionRoles...) +} + +func extractMessages(value any, wantedRoles ...string) []string { items, ok := value.([]any) if !ok { return nil } + wanted := make(map[string]struct{}, len(wantedRoles)) + for _, role := range wantedRoles { + wanted[strings.ToLower(strings.TrimSpace(role))] = struct{}{} + } result := make([]string, 0, len(items)) for _, item := range items { message, ok := item.(map[string]any) - if !ok || !strings.EqualFold(stringValue(message["role"]), wantedRole) { + if !ok { + continue + } + role := strings.ToLower(stringValue(message["role"])) + if _, match := wanted[role]; !match { continue } texts := contentTexts(message["content"]) @@ -107,6 +128,34 @@ func extractMessages(value any, wantedRole string) []string { return result } +func extractInstructions(value any) []string { + switch typed := value.(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + return []string{text} + } + case []any: + return contentTexts(typed) + case map[string]any: + return contentTexts(typed) + } + return nil +} + +func extractAnthropicSystem(value any) []string { + switch typed := value.(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + return []string{text} + } + case []any: + return contentTexts(typed) + case map[string]any: + return contentTexts(typed) + } + return nil +} + func extractResponses(value any) []string { switch typed := value.(type) { case string: @@ -119,7 +168,7 @@ func extractResponses(value any) []string { result = append(result, entry) case map[string]any: role := strings.ToLower(stringValue(entry["role"])) - if role != "" && role != "user" { + if role != "" && role != "user" && role != "system" && role != "developer" { continue } if content, exists := entry["content"]; exists { @@ -134,7 +183,7 @@ func extractResponses(value any) []string { return result case map[string]any: role := strings.ToLower(stringValue(typed["role"])) - if role != "" && role != "user" { + if role != "" && role != "user" && role != "system" && role != "developer" { return nil } return contentTexts(typed["content"]) @@ -179,7 +228,9 @@ func extractGeminiRoot(root map[string]any) []string { if root == nil { return nil } - result := extractGemini(root["contents"]) + result := extractGeminiSystemInstruction(root["systemInstruction"]) + result = append(result, extractGeminiSystemInstruction(root["system_instruction"])...) + result = append(result, extractGemini(root["contents"])...) result = append(result, extractGemini(root["content"])...) result = append(result, extractGeminiInstances(root["instances"])...) if requests, ok := root["requests"].([]any); ok { @@ -188,6 +239,8 @@ func extractGeminiRoot(root map[string]any) []string { if !ok { continue } + result = append(result, extractGeminiSystemInstruction(request["systemInstruction"])...) + result = append(result, extractGeminiSystemInstruction(request["system_instruction"])...) result = append(result, extractGemini(request["contents"])...) result = append(result, extractGemini(request["content"])...) result = append(result, extractGeminiInstances(request["instances"])...) @@ -196,6 +249,31 @@ func extractGeminiRoot(root map[string]any) []string { return result } +func extractGeminiSystemInstruction(value any) []string { + switch typed := value.(type) { + case string: + if text := strings.TrimSpace(typed); text != "" { + return []string{text} + } + case map[string]any: + if parts, ok := typed["parts"].([]any); ok { + result := make([]string, 0, len(parts)) + for _, part := range parts { + if object, ok := part.(map[string]any); ok { + if text := stringValue(object["text"]); text != "" { + result = append(result, text) + } + } + } + return result + } + return contentTexts(typed) + case []any: + return extractGemini(typed) + } + return nil +} + func extractGeminiInstances(value any) []string { instances, ok := value.([]any) if !ok { @@ -344,33 +422,42 @@ func RedactPreview(value string, maxRunes int) string { return TrimRunes(value, maxRunes) } -// BuildPromptPreview always withholds part of the sanitized input. Even short, -// otherwise-benign prompts must not become a recoverable raw-prompt database -// field merely because no secret pattern happened to match. +// BuildPromptPreview stores only a short, non-recoverable head of sanitized +// input. Ordinary confidential prompts must not land nearly intact in PostgreSQL +// or the admin UI merely because no secret regex matched. func BuildPromptPreview(value string, maxRunes int) string { + if maxRunes <= 0 { + maxRunes = DefaultPromptPreviewMaxRunes + } redacted := strings.TrimSpace(RedactPreview(value, maxRunes)) if redacted == "" { return "" } runes := []rune(redacted) hadTruncation := strings.HasSuffix(redacted, "…") - visibleLength := len(runes) - if hadTruncation && visibleLength > 0 { - visibleLength-- + if hadTruncation && len(runes) > 0 { + runes = runes[:len(runes)-1] } - maskCount := visibleLength / 4 - if maskCount < 1 { - maskCount = 1 + if len(runes) == 0 { + return "***…" } - if maskCount > 16 { - maskCount = 16 + // Short unlabelled secrets would otherwise leak a recoverable prefix (e.g. + // 20 runes → 5 visible). Fully withhold anything below the keep threshold. + const minLengthForPartialPreview = 32 + if len(runes) < minLengthForPartialPreview { + if hadTruncation { + return "***…" + } + return "***" } - keep := visibleLength - maskCount - if keep < 0 { - keep = 0 + // Keep at most a quarter of the already-truncated text, and never more than + // 24 runes, so the majority of prompt content is withheld by default. + keep := len(runes) / 4 + if keep > 24 { + keep = 24 } preview := string(runes[:keep]) + "***" - if hadTruncation { + if hadTruncation || keep < len(runes) { preview += "…" } return preview diff --git a/backend/internal/securityaudit/prompt_snapshot_test.go b/backend/internal/securityaudit/prompt_snapshot_test.go index 5765e1a51..17ab36371 100644 --- a/backend/internal/securityaudit/prompt_snapshot_test.go +++ b/backend/internal/securityaudit/prompt_snapshot_test.go @@ -180,6 +180,72 @@ func TestPromptSnapshotEmptyAndLongUnicodeInput(t *testing.T) { } } +func TestPromptSnapshotIncludesClientControlledInstructions(t *testing.T) { + tests := []struct { + name, protocol, body string + want []string + }{ + { + name: "openai system and developer", + protocol: "openai_chat_completions", + body: `{"messages":[{"role":"system","content":"system jailbreak"},{"role":"developer","content":"developer policy"},{"role":"assistant","content":"ignore"},{"role":"user","content":"hello"}]}`, + want: []string{"system jailbreak", "developer policy", "hello"}, + }, + { + name: "openai system only", + protocol: "openai_chat_completions", + body: `{"messages":[{"role":"system","content":"only system instruction"}]}`, + want: []string{"only system instruction"}, + }, + { + name: "responses instructions", + protocol: "openai_responses", + body: `{"instructions":"response instructions","input":[{"role":"user","content":[{"type":"input_text","text":"user turn"}]}]}`, + want: []string{"response instructions", "user turn"}, + }, + { + name: "anthropic system", + protocol: "anthropic_messages", + body: `{"system":"claude system","messages":[{"role":"user","content":[{"type":"text","text":"claude user"}]}]}`, + want: []string{"claude system", "claude user"}, + }, + { + name: "gemini systemInstruction", + protocol: "gemini", + body: `{"systemInstruction":{"parts":[{"text":"gemini system"}]},"contents":[{"role":"user","parts":[{"text":"gemini user"}]}]}`, + want: []string{"gemini system", "gemini user"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + snapshot, err := ExtractPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body)}) + require.NoError(t, err) + for _, expected := range tt.want { + require.Contains(t, snapshot.ScanText, expected) + } + require.NotContains(t, snapshot.ScanText, "ignore") + }) + } +} + +func TestBuildPromptPreviewWithholdsMajorityOfOrdinaryText(t *testing.T) { + prompt := strings.Repeat("机密业务提示词内容", 40) + preview := BuildPromptPreview(prompt, DefaultPromptPreviewMaxRunes) + require.NotEmpty(t, preview) + require.Contains(t, preview, "***") + require.LessOrEqual(t, utf8.RuneCountInString(strings.TrimSuffix(strings.TrimSuffix(preview, "…"), "***")), 24) + require.Less(t, utf8.RuneCountInString(preview), utf8.RuneCountInString(prompt)/2) + require.NotContains(t, preview, prompt) +} + +func TestBuildPromptPreviewFullyMasksShortUnlabelledSecrets(t *testing.T) { + require.Equal(t, "***", BuildPromptPreview("short-secret-value!!", DefaultPromptPreviewMaxRunes)) + require.Equal(t, "***", BuildPromptPreview(strings.Repeat("a", 31), DefaultPromptPreviewMaxRunes)) + partial := BuildPromptPreview(strings.Repeat("b", 32), DefaultPromptPreviewMaxRunes) + require.True(t, strings.HasPrefix(partial, "b")) + require.Contains(t, partial, "***") +} + func mustJSON(t *testing.T, value string) []byte { t.Helper() raw, err := json.Marshal(value) diff --git a/backend/internal/securityaudit/prompt_worker_test.go b/backend/internal/securityaudit/prompt_worker_test.go index 9cbd6c1cb..6c7ed7f3b 100644 --- a/backend/internal/securityaudit/prompt_worker_test.go +++ b/backend/internal/securityaudit/prompt_worker_test.go @@ -39,12 +39,16 @@ func (s *fakeConfigStore) Start(context.Context) error { return nil } func (s *fakeConfigStore) Shutdown(context.Context) error { return nil } func (s *fakeConfigStore) Active() (ActiveConfig, bool) { return cloneActiveConfig(s.cfg), s.active } func (s *fakeConfigStore) EffectiveMode() Mode { + if s.BlockingActivationDegraded() { + return ModeBlocking + } if !s.active { return ModeOff } return s.cfg.EffectiveMode() } -func (s *fakeConfigStore) Public() PublicConfig { return PublicConfig{} } +func (s *fakeConfigStore) BlockingActivationDegraded() bool { return false } +func (s *fakeConfigStore) Public() PublicConfig { return PublicConfig{} } func (s *fakeConfigStore) Save(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) { return PublicConfig{}, nil } diff --git a/backend/migrations/181_prompt_audit.sql b/backend/migrations/181_prompt_audit.sql index 6ca18f872..61f673c2f 100644 --- a/backend/migrations/181_prompt_audit.sql +++ b/backend/migrations/181_prompt_audit.sql @@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS prompt_audit_jobs ( redacted_preview TEXT NOT NULL DEFAULT '', prompt_length INT NOT NULL DEFAULT 0, message_count INT NOT NULL DEFAULT 0, + stage VARCHAR(32) NOT NULL DEFAULT 'http', execution_mode VARCHAR(32) NOT NULL DEFAULT 'async_audit', config_version BIGINT NOT NULL DEFAULT 1, status VARCHAR(32) NOT NULL DEFAULT 'staging', @@ -60,6 +61,7 @@ CREATE TABLE IF NOT EXISTS prompt_audit_events ( model VARCHAR(255) NOT NULL DEFAULT '', prompt_hash VARCHAR(64) NOT NULL DEFAULT '', redacted_preview TEXT NOT NULL DEFAULT '', + stage VARCHAR(32) NOT NULL DEFAULT 'http', decision VARCHAR(32) NOT NULL DEFAULT 'pass', risk_level VARCHAR(32) NOT NULL DEFAULT 'low', action VARCHAR(32) NOT NULL DEFAULT 'Allow', diff --git a/frontend/src/features/prompt-audit/components/EventDetailDialog.vue b/frontend/src/features/prompt-audit/components/EventDetailDialog.vue index c606a394b..f97737916 100644 --- a/frontend/src/features/prompt-audit/components/EventDetailDialog.vue +++ b/frontend/src/features/prompt-audit/components/EventDetailDialog.vue @@ -45,7 +45,8 @@
Config
v{{ event.config_version }}
Chunks
{{ event.chunk_total }}
Latency
{{ event.latency_ms }} ms
-
Protocol
{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }} · {{ event.snapshot.stage }}
+
{{ t('admin.promptAudit.events.stage') }}
{{ event.snapshot.stage || 'http' }}
+
Protocol
{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }}
diff --git a/frontend/src/features/prompt-audit/components/EventWorkspace.vue b/frontend/src/features/prompt-audit/components/EventWorkspace.vue index 1bf166a89..9e6464a5a 100644 --- a/frontend/src/features/prompt-audit/components/EventWorkspace.vue +++ b/frontend/src/features/prompt-audit/components/EventWorkspace.vue @@ -79,7 +79,7 @@ {{ event.snapshot.group_name || '—' }}

{{ event.snapshot.endpoint }}

-

{{ event.snapshot.model }} · {{ event.snapshot.protocol }}

+

{{ event.snapshot.model }} · {{ event.snapshot.protocol }} · {{ event.snapshot.stage || 'http' }}

{{ event.decision }} · {{ event.risk_level }} diff --git a/frontend/src/i18n/locales/en/admin/promptAudit.ts b/frontend/src/i18n/locales/en/admin/promptAudit.ts index cbe8113b3..8e214d116 100644 --- a/frontend/src/i18n/locales/en/admin/promptAudit.ts +++ b/frontend/src/i18n/locales/en/admin/promptAudit.ts @@ -40,7 +40,7 @@ export default { title: 'Audit events', description: 'Review redacted events by identity, route, risk, hash, and time.', decision: 'Decision', risk: 'Risk level', endpoint: 'Endpoint', groupId: 'Group ID', userId: 'User ID', apiKeyId: 'API Key ID', keyword: 'Keyword', startAt: 'Start time', endAt: 'End time', deleteSelected: 'Delete selected ({count})', deleteByFilter: 'Delete by filter', deleteRangeHint: 'Filter deletion requires explicit start and end times and a server-generated preview.', selectAll: 'Select all events on this page', selectEvent: 'Select event {id}', time: 'Time', identity: 'User / email / API Key', user: 'Username', email: 'User email', apiKey: 'API Key name', group: 'Group', route: 'Endpoint / model', result: 'Decision / risk', preview: 'Redacted preview', empty: 'No matching events.', - detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' }, redactedPreview: 'Irreversible redacted preview', categories: 'Categories', model: 'Model', noRisks: 'No derived risk summaries for this event.', + detailTitle: 'Prompt audit event details', tabs: { summary: 'Audit summary', risks: 'Specific risks', technical: 'Technical details' }, redactedPreview: 'Irreversible redacted preview', categories: 'Categories', model: 'Model', stage: 'Request stage', noRisks: 'No derived risk summaries for this event.', deleteConfirmTitle: 'Delete audit events?', deleteConfirmMessage: 'This permanently deletes {count} events and eligible orphan jobs.', filterDeleteTitle: 'Confirm filter deletion', filterDeleteCount: 'The server snapshot matches {count} events.', snapshotMax: 'Snapshot maximum event ID', expiresAt: 'Confirmation token expires', filterDeleteWarning: 'Only events at or below the preview high-water mark are deleted. Newer events survive. Any filter change requires a new preview.', confirmFilterDelete: 'Permanently delete', }, messages: { saved: 'Prompt Audit configuration saved; plaintext API Key state was cleared.', probeSucceeded: 'The audit node is reachable.', deleted: 'Deleted {count} audit events.' }, diff --git a/frontend/src/i18n/locales/zh/admin/promptAudit.ts b/frontend/src/i18n/locales/zh/admin/promptAudit.ts index c39f33e46..5bc5f6a81 100644 --- a/frontend/src/i18n/locales/zh/admin/promptAudit.ts +++ b/frontend/src/i18n/locales/zh/admin/promptAudit.ts @@ -40,7 +40,7 @@ export default { title: '审计事件', description: '按身份、入口、风险、Hash 和时间复核脱敏事件。', decision: '判定', risk: '风险等级', endpoint: '入口', groupId: '分组 ID', userId: '用户 ID', apiKeyId: 'API Key ID', keyword: '关键词', startAt: '开始时间', endAt: '结束时间', deleteSelected: '删除选中项({count})', deleteByFilter: '按筛选删除', deleteRangeHint: '按筛选删除必须明确选择开始和结束时间,并先取得服务端删除预览。', selectAll: '选择当前页全部事件', selectEvent: '选择事件 {id}', time: '时间', identity: '用户 / 邮箱 / API Key', user: '用户名', email: '用户邮箱', apiKey: 'API Key 名称', group: '分组', route: '入口 / 模型', result: '判定 / 风险', preview: '脱敏预览', empty: '没有符合条件的事件。', - detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' }, redactedPreview: '不可逆脱敏预览', categories: '分类', model: '模型', noRisks: '本事件没有派生风险摘要。', + detailTitle: '提示词审计事件详情', tabs: { summary: '审计摘要', risks: '具体风险', technical: '技术信息' }, redactedPreview: '不可逆脱敏预览', categories: '分类', model: '模型', stage: '请求阶段', noRisks: '本事件没有派生风险摘要。', deleteConfirmTitle: '删除审计事件?', deleteConfirmMessage: '将永久删除 {count} 条事件及符合条件的孤立任务。', filterDeleteTitle: '确认按筛选删除', filterDeleteCount: '服务端快照匹配 {count} 条事件。', snapshotMax: '快照最大事件 ID', expiresAt: '确认令牌过期时间', filterDeleteWarning: '只删除预览高水位内的事件;预览后产生的新事件会保留。筛选一旦变化,必须重新预览。', confirmFilterDelete: '确认永久删除', }, messages: { saved: '提示词审计配置已保存,明文 API Key 状态已清除。', probeSucceeded: '审计节点连接正常。', deleted: '已删除 {count} 条审计事件。' },