fix(security-audit): reject unavailable prompt config

This commit is contained in:
wucm667
2026-07-26 14:33:59 +08:00
parent 2730c1c43b
commit 56b5f0df68
8 changed files with 103 additions and 15 deletions
@@ -44,7 +44,7 @@ type ConfigStore interface {
// 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
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)
@@ -194,15 +194,15 @@ func (m *ConfigManager) markUntrustedIfNoActiveSnapshot() {
}
}
func (m *ConfigManager) Public() PublicConfig {
func (m *ConfigManager) Public() (PublicConfig, error) {
if m == nil {
return PublicFromStorage(DefaultStorageConfig(), false)
return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
}
snapshot := m.snapshot.Load()
if snapshot == nil {
return PublicFromStorage(DefaultStorageConfig(), false)
return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
}
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled)
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil
}
func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
@@ -56,6 +56,57 @@ func TestConfigRuntimeLoadErrorIsStableBoundedAndSecretFree(t *testing.T) {
require.LessOrEqual(t, len([]rune(message)), 160)
}
func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
t.Run("absent persisted setting is legitimate default", func(t *testing.T) {
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: "",
SettingKeyRiskControl: "false",
}}, nil, prefixEncryptor{})
require.NoError(t, manager.Reload(context.Background()))
public, err := manager.Public()
require.NoError(t, err)
require.Equal(t, int64(1), public.ConfigVersion)
require.False(t, public.Enabled)
})
t.Run("persisted config activation failure is unavailable", func(t *testing.T) {
const canary = "persisted-token-canary"
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`,
SettingKeyRiskControl: "true",
}}, nil, prefixEncryptor{})
require.Error(t, manager.Reload(context.Background()))
public, err := manager.Public()
require.Error(t, err)
require.Empty(t, public)
require.Equal(t, ErrorCodeConfigUnavailable, infraerrors.Reason(err))
require.NotContains(t, err.Error(), canary)
})
t.Run("reload failure preserves last successfully loaded snapshot", func(t *testing.T) {
storage := DefaultStorageConfig()
storage.ConfigVersion = 4
storage.ChangeSummary = "trusted snapshot"
raw, err := json.Marshal(storage)
require.NoError(t, err)
repository := &switchableSettingRepository{staticSettingRepository: staticSettingRepository{values: map[string]string{
SettingKeyPromptAuditConfig: string(raw),
SettingKeyRiskControl: "false",
}}}
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
require.NoError(t, manager.Reload(context.Background()))
repository.loadErr = errors.New("settings unavailable")
require.Error(t, manager.Reload(context.Background()))
public, err := manager.Public()
require.NoError(t, err)
require.Equal(t, int64(4), public.ConfigVersion)
require.Equal(t, "trusted snapshot", public.ChangeSummary)
})
}
func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
manager := &ConfigManager{encryptor: prefixEncryptor{}}
current := DefaultStorageConfig()
@@ -137,6 +188,18 @@ func (errorSettingRepository) GetMultiple(context.Context, []string) (map[string
return nil, errors.New("settings unavailable")
}
type switchableSettingRepository struct {
staticSettingRepository
loadErr error
}
func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
if r.loadErr != nil {
return nil, r.loadErr
}
return r.staticSettingRepository.GetMultiple(ctx, keys)
}
func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) {
// Settings unavailable and no prior blocking intent: stay ModeOff so the
// gateway remains usable and admins can still disable/configure Prompt Audit.
@@ -13,7 +13,7 @@ import (
)
type PromptAdminService interface {
GetConfig() PublicConfig
GetConfig() (PublicConfig, error)
SaveConfig(context.Context, UpdateConfigRequest, int64) (PublicConfig, error)
Probe(context.Context, ProbeRequest) ProbeResult
Runtime(context.Context) RuntimeSnapshot
@@ -31,7 +31,14 @@ func NewPromptAdminHandler(service PromptAdminService) *PromptAdminHandler {
return &PromptAdminHandler{service: service}
}
func (h *PromptAdminHandler) GetConfig(c *gin.Context) { response.Success(c, h.service.GetConfig()) }
func (h *PromptAdminHandler) GetConfig(c *gin.Context) {
config, err := h.service.GetConfig()
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, config)
}
func (h *PromptAdminHandler) UpdateConfig(c *gin.Context) {
var request UpdateConfigRequest
@@ -29,7 +29,9 @@ type fakePromptAdminService struct {
deleteFilter func(context.Context, DeleteByFilterRequest, int64) (*DeleteResult, error)
}
func (s *fakePromptAdminService) GetConfig() PublicConfig { return s.config }
func (s *fakePromptAdminService) GetConfig() (PublicConfig, error) {
return s.config, nil
}
func (s *fakePromptAdminService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
if s.save == nil {
return PublicConfig{}, errors.New("unexpected SaveConfig call")
@@ -156,6 +158,21 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test
})
}
func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) {
const canary = "persisted-config-secret-canary"
repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)}
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
require.Error(t, manager.Reload(context.Background()))
service := &PromptService{config: manager}
response := promptAdminRequest(t, promptAdminRouter(service), http.MethodGet, "/admin/prompt-audit/config", nil)
require.Equal(t, http.StatusServiceUnavailable, response.Code)
require.Contains(t, response.Body.String(), ErrorCodeConfigUnavailable)
require.NotContains(t, response.Body.String(), canary)
require.NotContains(t, response.Body.String(), `"config_version"`)
require.NotContains(t, response.Body.String(), `"token"`)
}
func TestPromptAdminProbeSupportsTemporaryOrSavedTokenWithoutEcho(t *testing.T) {
const canary = "probe-token-canary"
for _, tc := range []struct {
@@ -166,7 +166,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis
return s.evaluator.Evaluate(ctx, cfg, snapshot)
}
func (s *PromptService) GetConfig() PublicConfig { return s.config.Public() }
func (s *PromptService) GetConfig() (PublicConfig, error) { return s.config.Public() }
func (s *PromptService) SaveConfig(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
return s.config.Save(ctx, req, actorID)
@@ -12,11 +12,12 @@ const (
ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate"
PayloadKeyPrefix = "sub2api:prompt_audit:payload:"
ErrorCodeBlocked = "prompt_guard_blocked"
ErrorCodeUnavailable = "prompt_guard_unavailable"
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
ErrorCodeBlocked = "prompt_guard_blocked"
ErrorCodeUnavailable = "prompt_guard_unavailable"
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
DefaultGuardModel = "sileader/qwen3guard:0.6b"
)
@@ -48,7 +48,7 @@ func (s *fakeConfigStore) EffectiveMode() Mode {
return s.cfg.EffectiveMode()
}
func (s *fakeConfigStore) BlockingActivationDegraded() bool { return false }
func (s *fakeConfigStore) Public() PublicConfig { return PublicConfig{} }
func (s *fakeConfigStore) Public() (PublicConfig, error) { return PublicConfig{}, nil }
func (s *fakeConfigStore) Save(context.Context, UpdateConfigRequest, int64) (PublicConfig, error) {
return PublicConfig{}, nil
}