mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-08-31 01:13:06 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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} 条审计事件。' },
|
||||
|
||||
Reference in New Issue
Block a user