fix(securityaudit): log prompt_guard.config_loaded only on change
ConfigManager.refreshLoop reloads the Prompt Guard config every 5s and Reload logged config_loaded on every successful load, so an unchanged config produced up to ~17k identical lines per instance per day and buried real configuration changes. Log the event only when the reload carries news: the first snapshot, a new config version (every admin save bumps it under the advisory lock in UpdateConfig), a flip of the global risk control gate (a separate setting that leaves the version untouched), or a recovery from a failed reload so the degraded-to-healthy transition stays visible. This mirrors logInvalidTokenEndpoints, which already warns once per change rather than on every refresh.
This commit is contained in:
@@ -136,14 +136,28 @@ func (m *ConfigManager) Reload(ctx context.Context) error {
|
||||
previous := m.snapshot.Load()
|
||||
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
|
||||
m.configUntrusted.Store(false)
|
||||
m.clearLoadError()
|
||||
recovered := m.clearLoadError()
|
||||
m.logInvalidTokenEndpoints(previous, active)
|
||||
LogInfo(EventConfigLoaded, map[string]any{
|
||||
"config_version": storage.ConfigVersion, "status": "loaded",
|
||||
})
|
||||
// refreshLoop calls Reload every 5s, so logging every successful load turns
|
||||
// config_loaded into a heartbeat that buries real config changes.
|
||||
if recovered || shouldLogConfigLoaded(previous, storage, active) {
|
||||
LogInfo(EventConfigLoaded, map[string]any{
|
||||
"config_version": storage.ConfigVersion, "status": "loaded",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldLogConfigLoaded reports whether a successful reload carries news: the
|
||||
// first snapshot, a new config version (every admin save bumps it under the
|
||||
// advisory lock in UpdateConfig) or a flip of the global risk control gate,
|
||||
// which lives in its own setting and so leaves the version untouched.
|
||||
func shouldLogConfigLoaded(previous *activeConfigSnapshot, storage storageConfig, active ActiveConfig) bool {
|
||||
return previous == nil ||
|
||||
previous.storage.ConfigVersion != storage.ConfigVersion ||
|
||||
previous.active.RiskControlEnabled != active.RiskControlEnabled
|
||||
}
|
||||
|
||||
// logInvalidTokenEndpoints warns once per change (not on every 5s refresh)
|
||||
// when stored endpoint tokens cannot be decrypted with the current key.
|
||||
func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) {
|
||||
@@ -490,11 +504,15 @@ func (m *ConfigManager) recordLoadError(_ error) {
|
||||
m.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *ConfigManager) clearLoadError() {
|
||||
// clearLoadError drops the recorded load failure and reports whether one was
|
||||
// pending, so callers can tell a recovery apart from an unchanged reload.
|
||||
func (m *ConfigManager) clearLoadError() bool {
|
||||
m.stateMu.Lock()
|
||||
recovered := m.lastLoadError != ""
|
||||
m.lastLoadError = ""
|
||||
m.lastErrorAt = nil
|
||||
m.stateMu.Unlock()
|
||||
return recovered
|
||||
}
|
||||
|
||||
func cloneStorageConfig(cfg storageConfig) storageConfig {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package securityaudit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -454,3 +456,51 @@ func TestUpdateConfigStrictBoundsAndKnownValues(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Regression coverage for issue #5732: refreshLoop reloads every 5s, so
|
||||
// config_loaded must stay a change signal instead of ~17k identical lines a
|
||||
// day, while still reporting the first load, real config changes and a
|
||||
// recovery from a failed reload.
|
||||
func TestConfigLoadedIsLoggedOnlyWhenSomethingChanged(t *testing.T) {
|
||||
storage := DefaultStorageConfig()
|
||||
storage.ConfigVersion = 4
|
||||
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{}, testTotpKeyConfig())
|
||||
|
||||
var output bytes.Buffer
|
||||
previous := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(&output, nil)))
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
loadedCount := func() int { return strings.Count(output.String(), EventConfigLoaded) }
|
||||
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
require.Equal(t, 1, loadedCount(), "the first successful load must be logged")
|
||||
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
require.Equal(t, 1, loadedCount(), "TTL refreshes of an unchanged config must stay silent")
|
||||
|
||||
repository.values[SettingKeyRiskControl] = "true"
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
require.Equal(t, 2, loadedCount(), "flipping the global risk control gate must be logged")
|
||||
|
||||
storage.ConfigVersion = 5
|
||||
raw, err = json.Marshal(storage)
|
||||
require.NoError(t, err)
|
||||
repository.values[SettingKeyPromptAuditConfig] = string(raw)
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
require.Equal(t, 3, loadedCount(), "a new config version must be logged")
|
||||
|
||||
repository.loadErr = errors.New("settings unavailable")
|
||||
require.Error(t, manager.Reload(context.Background()))
|
||||
require.Equal(t, 3, loadedCount(), "a failed reload must not claim a load")
|
||||
|
||||
repository.loadErr = nil
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
require.Equal(t, 4, loadedCount(), "recovering from a failed reload must be visible")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user