| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512 |
- package securityaudit
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "sort"
- "strings"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- const (
- DefaultWorkerCount = 4
- MaxWorkerCount = 32
- DefaultQueueCapacity = 32768
- MaxQueueCapacity = 100000
- DefaultTimeoutMS = 3000
- MinTimeoutMS = 100
- MaxTimeoutMS = 30000
- DefaultInputLimit = 4000
- MinInputLimit = 128
- MaxInputLimit = 100000
- DefaultPayloadTTL = 30 * time.Minute
- )
- type SecretEncryptor interface {
- Encrypt(plaintext string) (string, error)
- Decrypt(ciphertext string) (string, error)
- }
- // ConfigStore is the injectable boundary between hot-path prompt auditing and
- // the concrete settings/PostgreSQL/Redis-backed configuration manager.
- type ConfigStore interface {
- Start(ctx context.Context) error
- 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).
- // It must stay false when blocking is not intended, even if config is
- // untrusted—otherwise default-off deployments fail closed for all traffic.
- BlockingActivationDegraded() bool
- Public() (PublicConfig, error)
- Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error)
- RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string)
- Encrypt(value string) (string, error)
- Decrypt(value string) (string, error)
- }
- type StorageEndpoint struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Protocol string `json:"protocol"`
- BaseURL string `json:"base_url"`
- Model string `json:"model"`
- TokenCiphertext string `json:"token_ciphertext,omitempty"`
- TimeoutMS int `json:"timeout_ms"`
- InputLimit int `json:"input_limit"`
- Enabled bool `json:"enabled"`
- }
- type storageConfig struct {
- Enabled bool `json:"enabled"`
- BlockingEnabled bool `json:"blocking_enabled"`
- BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
- StorePassEvents bool `json:"store_pass_events"`
- Strategy string `json:"strategy"`
- WorkerCount int `json:"worker_count"`
- QueueCapacity int `json:"queue_capacity"`
- Scanners []string `json:"scanners"`
- AllGroups bool `json:"all_groups"`
- GroupIDs []int64 `json:"group_ids"`
- Endpoints []StorageEndpoint `json:"endpoints"`
- ConfigVersion int64 `json:"config_version"`
- UpdatedAt time.Time `json:"updated_at"`
- UpdatedBy int64 `json:"updated_by"`
- ChangeSummary string `json:"change_summary"`
- }
- type ActiveEndpoint struct {
- ID string
- Name string
- Protocol string
- BaseURL string
- Model string
- Token string
- TimeoutMS int
- InputLimit int
- Enabled bool
- // TokenInvalid marks an endpoint whose persisted token ciphertext cannot be
- // decrypted with the current encryption key (key changed or auto-generated
- // on restart). The endpoint is kept visible for admins but excluded from
- // runtime use until the token is re-entered or cleared (issue #4887).
- TokenInvalid bool
- }
- type ActiveConfig struct {
- RiskControlEnabled bool
- Enabled bool
- BlockingEnabled bool
- BlockingLatestTurnOnly bool
- StorePassEvents bool
- Strategy string
- WorkerCount int
- QueueCapacity int
- Scanners []string
- AllGroups bool
- GroupIDs []int64
- Endpoints []ActiveEndpoint
- ConfigVersion int64
- UpdatedAt time.Time
- UpdatedBy int64
- ChangeSummary string
- }
- type PublicEndpoint struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Protocol string `json:"protocol"`
- BaseURL string `json:"base_url"`
- Model string `json:"model"`
- TimeoutMS int `json:"timeout_ms"`
- InputLimit int `json:"input_limit"`
- Enabled bool `json:"enabled"`
- HasToken bool `json:"has_token"`
- TokenStatus string `json:"token_status"`
- }
- type PublicConfig struct {
- Enabled bool `json:"enabled"`
- BlockingEnabled bool `json:"blocking_enabled"`
- BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
- StorePassEvents bool `json:"store_pass_events"`
- EffectiveMode Mode `json:"effective_mode"`
- Strategy string `json:"strategy"`
- WorkerCount int `json:"worker_count"`
- QueueCapacity int `json:"queue_capacity"`
- Scanners []string `json:"scanners"`
- AllGroups bool `json:"all_groups"`
- GroupIDs []int64 `json:"group_ids"`
- Endpoints []PublicEndpoint `json:"endpoints"`
- ConfigVersion int64 `json:"config_version"`
- UpdatedAt time.Time `json:"updated_at"`
- UpdatedBy int64 `json:"updated_by"`
- ChangeSummary string `json:"change_summary"`
- }
- type UpdateEndpoint struct {
- ID string `json:"id" binding:"required"`
- Name string `json:"name" binding:"required"`
- Protocol string `json:"protocol"`
- BaseURL string `json:"base_url" binding:"required"`
- Model string `json:"model"`
- Token string `json:"token,omitempty"`
- ClearToken bool `json:"clear_token"`
- TimeoutMS int `json:"timeout_ms"`
- InputLimit int `json:"input_limit"`
- Enabled bool `json:"enabled"`
- }
- type UpdateConfigRequest struct {
- ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"`
- Enabled bool `json:"enabled"`
- BlockingEnabled bool `json:"blocking_enabled"`
- BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
- StorePassEvents bool `json:"store_pass_events"`
- Strategy string `json:"strategy"`
- WorkerCount int `json:"worker_count"`
- QueueCapacity int `json:"queue_capacity"`
- Scanners []string `json:"scanners"`
- AllGroups bool `json:"all_groups"`
- GroupIDs []int64 `json:"group_ids"`
- Endpoints []UpdateEndpoint `json:"endpoints"`
- }
- func DefaultStorageConfig() storageConfig {
- return storageConfig{
- Enabled: false,
- BlockingEnabled: false,
- BlockingLatestTurnOnly: false,
- StorePassEvents: false,
- Strategy: "priority",
- WorkerCount: DefaultWorkerCount,
- QueueCapacity: DefaultQueueCapacity,
- Scanners: append([]string(nil), AllScannerIDs...),
- AllGroups: true,
- GroupIDs: []int64{},
- Endpoints: []StorageEndpoint{},
- ConfigVersion: 1,
- }
- }
- func ParseStorageConfig(raw string) (storageConfig, error) {
- cfg := DefaultStorageConfig()
- if strings.TrimSpace(raw) == "" {
- return cfg, nil
- }
- if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
- return storageConfig{}, fmt.Errorf("decode prompt audit config: %w", err)
- }
- normalizeStorageConfig(&cfg)
- if err := validateStorageConfig(cfg); err != nil {
- return storageConfig{}, err
- }
- return cfg, nil
- }
- func normalizeStorageConfig(cfg *storageConfig) {
- if cfg == nil {
- return
- }
- if cfg.ConfigVersion < 1 {
- cfg.ConfigVersion = 1
- }
- if strings.TrimSpace(cfg.Strategy) == "" {
- cfg.Strategy = "priority"
- }
- if cfg.WorkerCount == 0 {
- cfg.WorkerCount = DefaultWorkerCount
- }
- if cfg.QueueCapacity == 0 {
- cfg.QueueCapacity = DefaultQueueCapacity
- }
- if len(cfg.Scanners) == 0 {
- cfg.Scanners = append([]string(nil), AllScannerIDs...)
- }
- cfg.Scanners = canonicalScannerIDs(cfg.Scanners)
- cfg.GroupIDs = canonicalInt64s(cfg.GroupIDs)
- // Preserve an invalid blocking-without-audit combination so validation can
- // reject it instead of silently changing administrator intent.
- for i := range cfg.Endpoints {
- ep := &cfg.Endpoints[i]
- ep.ID = strings.TrimSpace(ep.ID)
- ep.Name = strings.TrimSpace(ep.Name)
- ep.Protocol = strings.TrimSpace(ep.Protocol)
- if ep.Protocol == "" {
- ep.Protocol = "openai_compatible"
- }
- ep.BaseURL = strings.TrimSpace(ep.BaseURL)
- ep.Model = strings.TrimSpace(ep.Model)
- if ep.Model == "" {
- ep.Model = DefaultGuardModel
- }
- if ep.TimeoutMS == 0 {
- ep.TimeoutMS = DefaultTimeoutMS
- }
- if ep.InputLimit == 0 {
- ep.InputLimit = DefaultInputLimit
- }
- }
- }
- func validateStorageConfig(cfg storageConfig) error {
- if cfg.BlockingEnabled && !cfg.Enabled {
- return infraerrors.BadRequest(ErrorCodeRequiresEnabled, "开启同步阻止前必须先启用提示词审计")
- }
- if cfg.Strategy != "priority" {
- return infraerrors.BadRequest("prompt_audit_invalid_strategy", "提示词审计策略仅支持 priority")
- }
- if cfg.WorkerCount < 1 || cfg.WorkerCount > MaxWorkerCount {
- return infraerrors.BadRequest("prompt_audit_invalid_worker_count", "Worker 数量超出允许范围")
- }
- if cfg.QueueCapacity < 1 || cfg.QueueCapacity > MaxQueueCapacity {
- return infraerrors.BadRequest("prompt_audit_invalid_queue_capacity", "队列容量超出允许范围")
- }
- if !cfg.AllGroups && len(cfg.GroupIDs) == 0 {
- return infraerrors.BadRequest("prompt_audit_groups_required", "指定分组模式至少需要选择一个分组")
- }
- if len(cfg.Scanners) == 0 {
- return infraerrors.BadRequest("prompt_audit_scanners_required", "至少需要启用一个风险分类")
- }
- seen := make(map[string]struct{}, len(cfg.Endpoints))
- enabled := 0
- for _, ep := range cfg.Endpoints {
- if ep.ID == "" || ep.Name == "" {
- return infraerrors.BadRequest("prompt_audit_invalid_endpoint", "审计节点 ID 和名称不能为空")
- }
- if _, ok := seen[ep.ID]; ok {
- return infraerrors.BadRequest("prompt_audit_duplicate_endpoint", "审计节点 ID 不能重复")
- }
- seen[ep.ID] = struct{}{}
- if ep.Protocol != "openai_compatible" {
- return infraerrors.BadRequest("prompt_audit_invalid_endpoint_protocol", "审计节点仅支持 OpenAI 兼容协议")
- }
- if _, err := NormalizeBaseURL(ep.BaseURL); err != nil {
- return err
- }
- if ep.TimeoutMS < MinTimeoutMS || ep.TimeoutMS > MaxTimeoutMS {
- return infraerrors.BadRequest("prompt_audit_invalid_timeout", "审计节点超时超出允许范围")
- }
- if ep.InputLimit < MinInputLimit || ep.InputLimit > MaxInputLimit {
- return infraerrors.BadRequest("prompt_audit_invalid_input_limit", "审计节点输入上限超出允许范围")
- }
- if ep.Enabled {
- enabled++
- }
- }
- if cfg.Enabled && enabled == 0 {
- return infraerrors.BadRequest("prompt_audit_endpoint_required", "启用提示词审计前至少需要启用一个审计节点")
- }
- return nil
- }
- func validateUpdateConfigRequest(req UpdateConfigRequest) error {
- if strings.TrimSpace(req.Strategy) != "priority" {
- return infraerrors.BadRequest("prompt_audit_invalid_strategy", "提示词审计策略仅支持 priority")
- }
- if req.WorkerCount < 1 || req.WorkerCount > MaxWorkerCount {
- return infraerrors.BadRequest("prompt_audit_invalid_worker_count", "Worker 数量超出允许范围")
- }
- if req.QueueCapacity < 1 || req.QueueCapacity > MaxQueueCapacity {
- return infraerrors.BadRequest("prompt_audit_invalid_queue_capacity", "队列容量超出允许范围")
- }
- if len(req.Scanners) == 0 {
- return infraerrors.BadRequest("prompt_audit_scanners_required", "至少需要启用一个风险分类")
- }
- for _, scanner := range req.Scanners {
- if _, ok := ScannerCatalog[NormalizeCategory(scanner)]; !ok {
- return infraerrors.BadRequest("prompt_audit_invalid_scanner", "提示词审计风险分类无效")
- }
- }
- if !req.AllGroups {
- if len(req.GroupIDs) == 0 {
- return infraerrors.BadRequest("prompt_audit_groups_required", "指定分组模式至少需要选择一个分组")
- }
- for _, groupID := range req.GroupIDs {
- if groupID <= 0 {
- return infraerrors.BadRequest("prompt_audit_invalid_group", "提示词审计分组 ID 无效")
- }
- }
- }
- for _, endpoint := range req.Endpoints {
- if endpoint.TimeoutMS < MinTimeoutMS || endpoint.TimeoutMS > MaxTimeoutMS {
- return infraerrors.BadRequest("prompt_audit_invalid_timeout", "审计节点超时超出允许范围")
- }
- if endpoint.InputLimit < MinInputLimit || endpoint.InputLimit > MaxInputLimit {
- return infraerrors.BadRequest("prompt_audit_invalid_input_limit", "审计节点输入上限超出允许范围")
- }
- }
- return nil
- }
- func (cfg ActiveConfig) EffectiveMode() Mode {
- if !cfg.RiskControlEnabled || !cfg.Enabled {
- return ModeOff
- }
- if cfg.BlockingEnabled {
- return ModeBlocking
- }
- return ModeAsync
- }
- func (cfg ActiveConfig) IncludesGroup(groupID *int64) bool {
- if cfg.AllGroups {
- return true
- }
- if groupID == nil {
- return false
- }
- i := sort.Search(len(cfg.GroupIDs), func(i int) bool { return cfg.GroupIDs[i] >= *groupID })
- return i < len(cfg.GroupIDs) && cfg.GroupIDs[i] == *groupID
- }
- func (cfg ActiveConfig) EnabledEndpoints() []ActiveEndpoint {
- result := make([]ActiveEndpoint, 0, len(cfg.Endpoints))
- for _, ep := range cfg.Endpoints {
- if ep.Enabled {
- result = append(result, ep)
- }
- }
- return result
- }
- // InvalidTokenEndpointIDs lists endpoints whose stored token could not be
- // decrypted with the current encryption key.
- func (cfg ActiveConfig) InvalidTokenEndpointIDs() []string {
- ids := make([]string, 0)
- for _, ep := range cfg.Endpoints {
- if ep.TokenInvalid {
- ids = append(ids, ep.ID)
- }
- }
- return ids
- }
- func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenEndpointIDs []string) PublicConfig {
- invalid := make(map[string]struct{}, len(invalidTokenEndpointIDs))
- for _, id := range invalidTokenEndpointIDs {
- invalid[id] = struct{}{}
- }
- scanners := append([]string{}, cfg.Scanners...)
- groupIDs := append([]int64{}, cfg.GroupIDs...)
- endpoints := make([]PublicEndpoint, 0, len(cfg.Endpoints))
- for _, ep := range cfg.Endpoints {
- hasToken := strings.TrimSpace(ep.TokenCiphertext) != ""
- status := "missing"
- if hasToken {
- status = "configured"
- if _, ok := invalid[ep.ID]; ok {
- status = "invalid"
- }
- }
- endpoints = append(endpoints, PublicEndpoint{
- ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL,
- Model: ep.Model, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit,
- Enabled: ep.Enabled, HasToken: hasToken, TokenStatus: status,
- })
- }
- active := ActiveConfig{RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled}
- return PublicConfig{
- Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, StorePassEvents: cfg.StorePassEvents,
- EffectiveMode: active.EffectiveMode(), Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
- QueueCapacity: cfg.QueueCapacity, Scanners: scanners, AllGroups: cfg.AllGroups,
- GroupIDs: groupIDs, Endpoints: endpoints, ConfigVersion: cfg.ConfigVersion,
- UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary,
- }
- }
- func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor SecretEncryptor) (ActiveConfig, error) {
- active := ActiveConfig{
- RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled,
- BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly,
- StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
- QueueCapacity: cfg.QueueCapacity, Scanners: append([]string(nil), cfg.Scanners...), AllGroups: cfg.AllGroups,
- GroupIDs: append([]int64(nil), cfg.GroupIDs...), ConfigVersion: cfg.ConfigVersion,
- UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary,
- Endpoints: make([]ActiveEndpoint, 0, len(cfg.Endpoints)),
- }
- for _, ep := range cfg.Endpoints {
- token := ""
- tokenInvalid := false
- if ep.TokenCiphertext != "" {
- if encryptor == nil {
- return ActiveConfig{}, fmt.Errorf("prompt audit secret encryptor unavailable")
- }
- plain, err := encryptor.Decrypt(ep.TokenCiphertext)
- if err != nil {
- // An undecryptable token (encryption key changed or regenerated)
- // must not take the whole config down: admins would otherwise be
- // locked out of the real config version and unable to recover
- // (issue #4887). Keep the ciphertext persisted, but exclude the
- // endpoint from runtime use until the token is re-entered.
- tokenInvalid = true
- } else {
- token = plain
- }
- }
- active.Endpoints = append(active.Endpoints, ActiveEndpoint{
- ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL, Model: ep.Model,
- Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit,
- Enabled: ep.Enabled && !tokenInvalid, TokenInvalid: tokenInvalid,
- })
- }
- return active, nil
- }
- func changeSummary(cfg storageConfig) string {
- summary := struct {
- Enabled bool `json:"enabled"`
- BlockingEnabled bool `json:"blocking_enabled"`
- BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
- StorePassEvents bool `json:"store_pass_events"`
- EndpointCount int `json:"endpoint_count"`
- ScannerCount int `json:"scanner_count"`
- AllGroups bool `json:"all_groups"`
- GroupCount int `json:"group_count"`
- GroupHash string `json:"group_hash"`
- }{cfg.Enabled, cfg.BlockingEnabled, cfg.BlockingLatestTurnOnly, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""}
- rawGroups, _ := json.Marshal(cfg.GroupIDs)
- digest := sha256.Sum256(rawGroups)
- summary.GroupHash = hex.EncodeToString(digest[:])
- raw, _ := json.Marshal(summary)
- return string(raw)
- }
- func canonicalInt64s(values []int64) []int64 {
- seen := make(map[int64]struct{}, len(values))
- result := make([]int64, 0, len(values))
- for _, value := range values {
- if value <= 0 {
- continue
- }
- if _, ok := seen[value]; ok {
- continue
- }
- seen[value] = struct{}{}
- result = append(result, value)
- }
- sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
- return result
- }
- func canonicalScannerIDs(values []string) []string {
- seen := make(map[string]struct{}, len(values))
- for _, value := range values {
- id := NormalizeCategory(value)
- if _, ok := ScannerCatalog[id]; ok {
- seen[id] = struct{}{}
- }
- }
- result := make([]string, 0, len(seen))
- for _, id := range AllScannerIDs {
- if _, ok := seen[id]; ok {
- result = append(result, id)
- }
- }
- return result
- }
|