fix(security-audit): close prompt-audit bypass and privacy gaps

Stop WebSocket follow-up turns from reusing a request-wide audit cache, scan
client-controlled instruction fields, fail closed on stale weaker configs, and
tighten preview/SSRF controls including persisted request stage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mt21625457
2026-07-17 08:46:57 +08:00
parent d11bdb13f5
commit 0f7f8a317e
18 changed files with 363 additions and 67 deletions
@@ -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 {
@@ -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
}
@@ -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)
@@ -129,15 +129,25 @@ func (m *ConfigManager) Active() (ActiveConfig, bool) {
return cloneActiveConfig(snapshot.active), true
}
func (m *ConfigManager) EffectiveMode() Mode {
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()
@@ -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)
@@ -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 {
@@ -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 {
@@ -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)
@@ -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
@@ -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,
+117 -30
View File
@@ -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
@@ -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)
@@ -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
}
+2
View File
@@ -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',
@@ -45,7 +45,8 @@
<dt class="text-gray-500">Config</dt><dd>v{{ event.config_version }}</dd>
<dt class="text-gray-500">Chunks</dt><dd>{{ event.chunk_total }}</dd>
<dt class="text-gray-500">Latency</dt><dd>{{ event.latency_ms }} ms</dd>
<dt class="text-gray-500">Protocol</dt><dd>{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }} · {{ event.snapshot.stage }}</dd>
<dt class="text-gray-500">{{ t('admin.promptAudit.events.stage') }}</dt><dd>{{ event.snapshot.stage || 'http' }}</dd>
<dt class="text-gray-500">Protocol</dt><dd>{{ event.snapshot.protocol }} · {{ event.snapshot.endpoint }}</dd>
</dl>
</div>
</BaseDialog>
@@ -79,7 +79,7 @@
<td class="px-3 py-3 text-gray-700 dark:text-dark-200">{{ event.snapshot.group_name || '—' }}</td>
<td class="px-3 py-3">
<p class="font-medium text-gray-900 dark:text-white">{{ event.snapshot.endpoint }}</p>
<p class="mt-1 text-xs text-gray-500">{{ event.snapshot.model }} · {{ event.snapshot.protocol }}</p>
<p class="mt-1 text-xs text-gray-500">{{ event.snapshot.model }} · {{ event.snapshot.protocol }} · {{ event.snapshot.stage || 'http' }}</p>
</td>
<td class="px-3 py-3">
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="decisionClass(event.decision)">{{ event.decision }} · {{ event.risk_level }}</span>
@@ -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.' },
@@ -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} 条审计事件。' },