Scan client-injected assistant/tool/model turns, fail closed when config cannot be trusted after startup or stale invalidation, reuse probe tokens only for the same base URL, and restrict localhost dials to loopback addresses. Co-authored-by: Cursor <cursoragent@cursor.com>
461 lines
14 KiB
Go
461 lines
14 KiB
Go
package securityaudit
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
type activeConfigSnapshot struct {
|
|
storage storageConfig
|
|
active ActiveConfig
|
|
loadedAt time.Time
|
|
}
|
|
|
|
type ConfigManager struct {
|
|
db *sql.DB
|
|
settings service.SettingRepository
|
|
redis *redis.Client
|
|
encryptor SecretEncryptor
|
|
clock Clock
|
|
|
|
snapshot atomic.Pointer[activeConfigSnapshot]
|
|
expected atomic.Int64
|
|
// expectedBlocking records the last storage intent that could be decoded,
|
|
// independently of whether endpoint credentials or the full config could be
|
|
// activated. A config version alone cannot distinguish async from blocking.
|
|
expectedBlocking atomic.Bool
|
|
// configUntrusted is set when a load/reload fails before a trustworthy
|
|
// snapshot is installed. While set, EffectiveMode fails closed so a
|
|
// persisted blocking policy cannot be silently skipped after startup or
|
|
// invalidation errors.
|
|
configUntrusted atomic.Bool
|
|
|
|
stateMu sync.RWMutex
|
|
lastLoadError string
|
|
lastErrorAt *time.Time
|
|
|
|
lifecycleMu sync.Mutex
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor) *ConfigManager {
|
|
return &ConfigManager{db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{}}
|
|
}
|
|
|
|
func (m *ConfigManager) Start(ctx context.Context) error {
|
|
if m == nil {
|
|
return errors.New("prompt audit config manager unavailable")
|
|
}
|
|
m.lifecycleMu.Lock()
|
|
if m.cancel != nil {
|
|
m.lifecycleMu.Unlock()
|
|
return nil
|
|
}
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
m.cancel = cancel
|
|
m.lifecycleMu.Unlock()
|
|
loadErr := m.Reload(runCtx)
|
|
if loadErr != nil {
|
|
m.markConfigUntrusted()
|
|
}
|
|
m.wg.Add(1)
|
|
go m.refreshLoop(runCtx)
|
|
if m.redis != nil {
|
|
m.wg.Add(1)
|
|
go m.subscribeLoop(runCtx)
|
|
}
|
|
return loadErr
|
|
}
|
|
|
|
func (m *ConfigManager) Shutdown(_ context.Context) error {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
m.lifecycleMu.Lock()
|
|
cancel := m.cancel
|
|
m.cancel = nil
|
|
m.lifecycleMu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
m.wg.Wait()
|
|
return nil
|
|
}
|
|
|
|
func (m *ConfigManager) Reload(ctx context.Context) error {
|
|
if m == nil || m.settings == nil {
|
|
m.markUntrustedIfNoActiveSnapshot()
|
|
return errors.New("prompt audit setting repository unavailable")
|
|
}
|
|
values, err := m.settings.GetMultiple(ctx, []string{SettingKeyPromptAuditConfig, SettingKeyRiskControl})
|
|
if err != nil {
|
|
m.recordLoadError(err)
|
|
m.markUntrustedIfNoActiveSnapshot()
|
|
return err
|
|
}
|
|
m.observeExpectedState(values[SettingKeyPromptAuditConfig], values[SettingKeyRiskControl] == "true")
|
|
storage, err := ParseStorageConfig(values[SettingKeyPromptAuditConfig])
|
|
if err != nil {
|
|
m.recordLoadError(err)
|
|
m.markUntrustedIfNoActiveSnapshot()
|
|
return err
|
|
}
|
|
m.expected.Store(storage.ConfigVersion)
|
|
m.expectedBlocking.Store(values[SettingKeyRiskControl] == "true" && storage.Enabled && storage.BlockingEnabled)
|
|
active, err := ActiveFromStorage(storage, values[SettingKeyRiskControl] == "true", m.encryptor)
|
|
if err != nil {
|
|
m.recordLoadError(err)
|
|
// expectedBlocking may already require fail-closed via BlockingActivationDegraded.
|
|
m.markUntrustedIfNoActiveSnapshot()
|
|
return err
|
|
}
|
|
now := m.clock.Now()
|
|
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
|
|
m.configUntrusted.Store(false)
|
|
m.clearLoadError()
|
|
LogInfo(EventConfigLoaded, map[string]any{
|
|
"config_version": storage.ConfigVersion, "status": "loaded",
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (m *ConfigManager) Active() (ActiveConfig, bool) {
|
|
if m == nil {
|
|
return ActiveConfig{}, false
|
|
}
|
|
snapshot := m.snapshot.Load()
|
|
if snapshot == nil {
|
|
return ActiveConfig{}, false
|
|
}
|
|
return cloneActiveConfig(snapshot.active), true
|
|
}
|
|
|
|
func (m *ConfigManager) BlockingActivationDegraded() bool {
|
|
if m == nil {
|
|
return false
|
|
}
|
|
if m.configUntrusted.Load() {
|
|
return true
|
|
}
|
|
if !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 {
|
|
return ModeOff
|
|
}
|
|
return active.EffectiveMode()
|
|
}
|
|
|
|
func (m *ConfigManager) markConfigUntrusted() {
|
|
if m == nil {
|
|
return
|
|
}
|
|
m.configUntrusted.Store(true)
|
|
}
|
|
|
|
func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() {
|
|
if m == nil {
|
|
return
|
|
}
|
|
if _, ok := m.Active(); !ok {
|
|
m.markConfigUntrusted()
|
|
}
|
|
}
|
|
|
|
func (m *ConfigManager) Public() PublicConfig {
|
|
if m == nil {
|
|
return PublicFromStorage(DefaultStorageConfig(), false)
|
|
}
|
|
snapshot := m.snapshot.Load()
|
|
if snapshot == nil {
|
|
return PublicFromStorage(DefaultStorageConfig(), false)
|
|
}
|
|
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled)
|
|
}
|
|
|
|
func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
|
|
if m == nil || m.db == nil || m.encryptor == nil {
|
|
return PublicConfig{}, errors.New("prompt audit config persistence unavailable")
|
|
}
|
|
if req.ExpectedConfigVersion < 1 {
|
|
return PublicConfig{}, infraerrors.BadRequest("prompt_audit_expected_config_version_required", "必须提供有效的配置版本")
|
|
}
|
|
tx, err := m.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
|
if err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, promptAuditConfigLockKey); err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
current := DefaultStorageConfig()
|
|
var raw string
|
|
err = tx.QueryRowContext(ctx, `SELECT value FROM settings WHERE key=$1 FOR UPDATE`, SettingKeyPromptAuditConfig).Scan(&raw)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return PublicConfig{}, err
|
|
}
|
|
if err == nil {
|
|
current, err = ParseStorageConfig(raw)
|
|
if err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
}
|
|
if current.ConfigVersion != req.ExpectedConfigVersion {
|
|
return PublicConfig{}, infraerrors.Conflict(ErrorCodeConfigConflict, "提示词审计配置已被其他管理员更新")
|
|
}
|
|
next, err := m.buildNextStorage(current, req, actorID)
|
|
if err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
next.ConfigVersion = current.ConfigVersion + 1
|
|
next.UpdatedAt = m.clock.Now()
|
|
next.UpdatedBy = actorID
|
|
next.ChangeSummary = changeSummary(next)
|
|
rawNext, err := json.Marshal(next)
|
|
if err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO settings (key,value,updated_at) VALUES ($1,$2,NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=EXCLUDED.updated_at`,
|
|
SettingKeyPromptAuditConfig, string(rawNext)); err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
// Install the snapshot with the current global gate, not merely the value
|
|
// cached when this process last reloaded Prompt Audit configuration.
|
|
riskControlEnabled := m.currentRiskControlEnabled()
|
|
if values, getErr := m.settings.GetMultiple(ctx, []string{SettingKeyRiskControl}); getErr == nil {
|
|
riskControlEnabled = values[SettingKeyRiskControl] == "true"
|
|
}
|
|
active, err := ActiveFromStorage(next, riskControlEnabled, m.encryptor)
|
|
if err != nil {
|
|
return PublicConfig{}, err
|
|
}
|
|
m.expected.Store(next.ConfigVersion)
|
|
m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled)
|
|
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(next), active: cloneActiveConfig(active), loadedAt: m.clock.Now()})
|
|
m.clearLoadError()
|
|
LogInfo(EventConfigUpdated, map[string]any{
|
|
"config_version": next.ConfigVersion, "status": "updated",
|
|
})
|
|
if m.redis != nil {
|
|
if err := m.redis.Publish(ctx, ConfigInvalidationChannel, strconv.FormatInt(next.ConfigVersion, 10)).Err(); err != nil {
|
|
LogWarn(EventConfigReloadDegraded, map[string]any{
|
|
"config_version": next.ConfigVersion, "status": "degraded", "error_code": "config_invalidation_publish_failed",
|
|
})
|
|
}
|
|
}
|
|
return PublicFromStorage(next, active.RiskControlEnabled), nil
|
|
}
|
|
|
|
func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfigRequest, actorID int64) (storageConfig, error) {
|
|
if err := validateUpdateConfigRequest(req); err != nil {
|
|
return storageConfig{}, err
|
|
}
|
|
currentByID := make(map[string]StorageEndpoint, len(current.Endpoints))
|
|
for _, endpoint := range current.Endpoints {
|
|
currentByID[endpoint.ID] = endpoint
|
|
}
|
|
next := storageConfig{
|
|
Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, StorePassEvents: req.StorePassEvents,
|
|
Strategy: strings.TrimSpace(req.Strategy), WorkerCount: req.WorkerCount,
|
|
QueueCapacity: req.QueueCapacity, Scanners: append([]string(nil), req.Scanners...),
|
|
AllGroups: req.AllGroups, GroupIDs: append([]int64(nil), req.GroupIDs...),
|
|
ConfigVersion: current.ConfigVersion, UpdatedBy: actorID,
|
|
Endpoints: make([]StorageEndpoint, 0, len(req.Endpoints)),
|
|
}
|
|
for _, endpoint := range req.Endpoints {
|
|
baseURL, err := NormalizeBaseURL(endpoint.BaseURL)
|
|
if err != nil {
|
|
return storageConfig{}, err
|
|
}
|
|
stored := StorageEndpoint{
|
|
ID: strings.TrimSpace(endpoint.ID), Name: strings.TrimSpace(endpoint.Name),
|
|
Protocol: strings.TrimSpace(endpoint.Protocol), BaseURL: baseURL, Model: strings.TrimSpace(endpoint.Model),
|
|
TimeoutMS: endpoint.TimeoutMS, InputLimit: endpoint.InputLimit, Enabled: endpoint.Enabled,
|
|
}
|
|
old, hadOld := currentByID[stored.ID]
|
|
switch {
|
|
case endpoint.ClearToken:
|
|
stored.TokenCiphertext = ""
|
|
case strings.TrimSpace(endpoint.Token) != "":
|
|
ciphertext, err := m.encryptor.Encrypt(strings.TrimSpace(endpoint.Token))
|
|
if err != nil {
|
|
return storageConfig{}, fmt.Errorf("encrypt prompt audit endpoint token: %w", err)
|
|
}
|
|
stored.TokenCiphertext = ciphertext
|
|
case hadOld:
|
|
stored.TokenCiphertext = old.TokenCiphertext
|
|
}
|
|
next.Endpoints = append(next.Endpoints, stored)
|
|
}
|
|
normalizeStorageConfig(&next)
|
|
if err := validateStorageConfig(next); err != nil {
|
|
return storageConfig{}, err
|
|
}
|
|
return next, nil
|
|
}
|
|
|
|
func (m *ConfigManager) RuntimeState() (expected int64, active int64, loadedAt *time.Time, loadError string) {
|
|
if m == nil {
|
|
return 1, 0, nil, "config_manager_unavailable"
|
|
}
|
|
expected = m.expected.Load()
|
|
if expected < 1 {
|
|
expected = 1
|
|
}
|
|
if snapshot := m.snapshot.Load(); snapshot != nil {
|
|
active = snapshot.active.ConfigVersion
|
|
value := snapshot.loadedAt
|
|
loadedAt = &value
|
|
}
|
|
m.stateMu.RLock()
|
|
loadError = m.lastLoadError
|
|
m.stateMu.RUnlock()
|
|
return
|
|
}
|
|
|
|
func (m *ConfigManager) Encrypt(value string) (string, error) { return m.encryptor.Encrypt(value) }
|
|
func (m *ConfigManager) Decrypt(value string) (string, error) { return m.encryptor.Decrypt(value) }
|
|
|
|
func (m *ConfigManager) currentRiskControlEnabled() bool {
|
|
if snapshot := m.snapshot.Load(); snapshot != nil {
|
|
return snapshot.active.RiskControlEnabled
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *ConfigManager) observeExpectedState(raw string, riskControlEnabled bool) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(raw) == "" {
|
|
m.expected.Store(1)
|
|
m.expectedBlocking.Store(false)
|
|
return
|
|
}
|
|
var intent struct {
|
|
Enabled bool `json:"enabled"`
|
|
BlockingEnabled bool `json:"blocking_enabled"`
|
|
ConfigVersion int64 `json:"config_version"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &intent); err != nil {
|
|
return
|
|
}
|
|
if intent.ConfigVersion < 1 {
|
|
intent.ConfigVersion = 1
|
|
}
|
|
m.expected.Store(intent.ConfigVersion)
|
|
m.expectedBlocking.Store(riskControlEnabled && intent.Enabled && intent.BlockingEnabled)
|
|
}
|
|
|
|
func (m *ConfigManager) refreshLoop(ctx context.Context) {
|
|
defer m.wg.Done()
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := m.Reload(ctx); err != nil {
|
|
LogWarn(EventConfigReloadDegraded, map[string]any{"status": "degraded", "error_code": "config_ttl_reload_failed"})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *ConfigManager) subscribeLoop(ctx context.Context) {
|
|
defer m.wg.Done()
|
|
pubsub := m.redis.Subscribe(ctx, ConfigInvalidationChannel)
|
|
defer func() { _ = pubsub.Close() }()
|
|
channel := pubsub.Channel()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case message, ok := <-channel:
|
|
if !ok {
|
|
return
|
|
}
|
|
version, err := strconv.ParseInt(strings.TrimSpace(message.Payload), 10, 64)
|
|
if err != nil || version < 1 {
|
|
continue
|
|
}
|
|
m.expected.Store(version)
|
|
if err := m.Reload(ctx); err != nil {
|
|
// A newer published version failed to activate. Until reload
|
|
// succeeds, do not keep serving a potentially stale weaker mode.
|
|
if active, ok := m.Active(); !ok || active.ConfigVersion < version {
|
|
m.markConfigUntrusted()
|
|
}
|
|
LogWarn(EventConfigReloadDegraded, map[string]any{
|
|
"config_version": version, "status": "degraded", "error_code": "config_invalidation_reload_failed",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *ConfigManager) recordLoadError(_ error) {
|
|
if m == nil {
|
|
return
|
|
}
|
|
now := m.clock.Now()
|
|
m.stateMu.Lock()
|
|
m.lastLoadError = stableErrorMessage("config_load_failed")
|
|
m.lastErrorAt = &now
|
|
m.stateMu.Unlock()
|
|
}
|
|
|
|
func (m *ConfigManager) clearLoadError() {
|
|
m.stateMu.Lock()
|
|
m.lastLoadError = ""
|
|
m.lastErrorAt = nil
|
|
m.stateMu.Unlock()
|
|
}
|
|
|
|
func cloneStorageConfig(cfg storageConfig) storageConfig {
|
|
cfg.Scanners = append([]string(nil), cfg.Scanners...)
|
|
cfg.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
|
|
cfg.Endpoints = append([]StorageEndpoint(nil), cfg.Endpoints...)
|
|
return cfg
|
|
}
|
|
|
|
func cloneActiveConfig(cfg ActiveConfig) ActiveConfig {
|
|
cfg.Scanners = append([]string(nil), cfg.Scanners...)
|
|
cfg.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
|
|
cfg.Endpoints = append([]ActiveEndpoint(nil), cfg.Endpoints...)
|
|
return cfg
|
|
}
|