diff --git a/AGENTS.md b/AGENTS.md index 4752fc9..4d2326f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,11 @@ change here must keep working with them. and `_policy_data_digest`. - **The agent never computes artifact digests.** It passes each evaluation's policy directory, input and policy data through to the API, which canonicalises and hashes them. +- **Evidence identity.** `policy-manager`'s `newEvidence` seed is every evidence stream's UUID, and plugins in the + field compute it. Never change it. The golden test in `policy-manager/evidence_seed_test.go` pins the UUIDs. +- **Plugin library version.** `internal/pluginlib` reads the agent library a plugin binary was built with from its + Go build info. The config report lists it per plugin (`plugins[].lib-version`) as diagnostics only; nothing is + gated on it. - **Storage failure doesn't drop evidence.** If artifact storage fails, the evidence is still sent, without digests. - **OCI policy bundles.** The agent evaluates the extracted `policies/` subdirectory, and that diff --git a/Makefile b/Makefile index 28fab22..18c6baa 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ proto-gen: ## Generate objects from proto definitions ##@ Test .PHONY: test test: ## Run tests - @if ! go test ./... -coverprofile cover.out -v; then \ + @if ! go test ./... -race -coverprofile cover.out -v; then \ $(WARN) "Tests failed"; \ exit 1; \ fi ; \ diff --git a/README.md b/README.md index 5af85be..8b519ce 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,18 @@ The API auth settings follow the same rule, so `api.auth.client_id` and `api.aut `CCF_API_AUTH_CLIENT_ID` and `CCF_API_AUTH_CLIENT_SECRET`. These values must be configured together; setting only one will fail agent startup validation. The `client_id` value must be a valid UUID. +Values that come from `CCF_PLUGINS_*` variables are masked in the configuration reports the agent sends to the API, +as are secret-like keys and values (see [configuration](./docs/configuration.md#envname-placeholders)). +Plugins never receive `CCF_API_AUTH_*` variables. + +### Remote configuration and state + +With `api.auth` credentials the agent reports its configuration to the API. With `remote_config.mode` set to +`apply_safe` or `apply_all` it also applies a configuration overlay stored there, including `${env:NAME}` placeholders +in plugin config; the default mode, `report`, never fetches or applies one. Each instance keeps a stable ID and a cache in a state directory (`--state-dir` / `CCF_STATE_DIR`; +`--instance-id` / `CCF_INSTANCE_ID`). See [configuration](./docs/configuration.md#remote-configuration) and +[ADR 0003](./docs/adr/0003-remote-config-overlay.md). + ## Usage To run the agent, you must first build the agent, and then run it with the `agent` command. It is recommended, diff --git a/cmd/agent.go b/cmd/agent.go index f817c41..664cf73 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -6,19 +6,24 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" + "maps" "math/rand" "net/http" "os" "os/exec" "os/signal" - "path" + "path/filepath" + "reflect" "runtime" + "slices" "sort" "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -27,19 +32,19 @@ import ( "github.com/robfig/cron/v3" "github.com/compliance-framework/agent/internal" + "github.com/compliance-framework/agent/internal/agentstate" + "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" sdktypes "github.com/compliance-framework/api/sdk/types" "github.com/coreos/go-systemd/v22/daemon" oscalTypes_1_1_3 "github.com/defenseunicorns/go-oscal/src/types/oscal-1-1-3" - "github.com/fsnotify/fsnotify" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-plugin" - "github.com/open-policy-agent/opa/rego" "github.com/spf13/cobra" - "github.com/spf13/viper" "golang.org/x/sync/singleflight" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -78,50 +83,56 @@ type agentEvidenceConfig struct { Interval string `mapstructure:"interval,omitempty"` } +// agentConfig is the RUNTIME form of the configuration, built from the declared form +// (agentconfig.Config) by toRuntime. It is immutable once handed to AgentRunner.UpdateConfig, +// except for the protocol resolution AgentRunner.Run performs on its own copy and the sync +// metadata, which the reconciler may update atomically when a new revision leaves the +// effective configuration unchanged. type agentConfig struct { Daemon bool `mapstructure:"daemon"` Verbosity int32 `mapstructure:"verbosity"` ApiConfig *apiConfig `mapstructure:"api"` Plugins map[string]*agentPlugin `mapstructure:"plugins"` AgentEvidence *agentEvidenceConfig `mapstructure:"agent_evidence"` + + // sync is what the heartbeat reports about the applied remote configuration (R11, R45). + // Read it with syncInfo; nil means the zero syncMeta. + sync *atomic.Pointer[syncMeta] + // remote is the normalized remote_config block. + remote agentconfig.RemoteConfig } -// logVerbosity reverses our verbosity "increase" to hclog's reversed "decrease." -// 1 for us means INFO. 1 for hclog means trace. -// 3 for us means TRACE. 3 for hclog means INFO. -// You can see hclog's verbosity here: https://github.com/hashicorp/go-hclog/blob/cb8687c9c619227eac510d0a76d23997fb6667d3/logger.go#L25 -func (ac *agentConfig) logVerbosity() int32 { - return int32(hclog.Info) - ac.Verbosity +// syncMeta describes the applied remote configuration. +type syncMeta struct { + AppliedRevision int64 // 0 when running the file only + Digest string // agentconfig.Digest of the effective declared config + Mode string // remote_config.mode } -func (ac *agentConfig) validate() error { - if err := ac.ApiConfig.validate(); err != nil { - return err +// syncInfo returns the sync metadata (safe for concurrent use with setSync). +func (ac *agentConfig) syncInfo() syncMeta { + if ac == nil || ac.sync == nil { + return syncMeta{} } - - if _, err := ac.agentEvidenceInterval(); err != nil { - return err + if p := ac.sync.Load(); p != nil { + return *p } + return syncMeta{} +} - for name, pluginConfig := range ac.Plugins { - if pluginConfig == nil { - return fmt.Errorf("plugin %s has null configuration", name) - } - - if pluginConfig.ProtocolVersion == 0 { - if pluginConfig.protocolSet { - return fmt.Errorf("plugin %s has unsupported protocol_version=%d; supported values are %d and %d", name, pluginConfig.ProtocolVersion, DefaultProtocolVersion, RunnerV2ProtocolVersion) - } - - continue - } - - if !isSupportedProtocolVersion(pluginConfig.ProtocolVersion) { - return fmt.Errorf("plugin %s has unsupported protocol_version=%d; supported values are %d and %d", name, pluginConfig.ProtocolVersion, DefaultProtocolVersion, RunnerV2ProtocolVersion) - } +// setSync stores the sync metadata. The first call must happen before the config is shared. +func (ac *agentConfig) setSync(m syncMeta) { + if ac.sync == nil { + ac.sync = &atomic.Pointer[syncMeta]{} } + ac.sync.Store(&m) +} - return nil +// logVerbosity maps our verbosity "increase" onto hclog's levels: our 0/1/2 = Info/Debug/Trace, +// i.e. hclog.Level(Info - v). See hclog's levels here: +// https://github.com/hashicorp/go-hclog/blob/cb8687c9c619227eac510d0a76d23997fb6667d3/logger.go#L25 +func (ac *agentConfig) logVerbosity() int32 { + return int32(hclog.Info) - ac.Verbosity } func (ac *agentConfig) agentEvidenceEnabled() bool { @@ -157,28 +168,6 @@ func (ac *agentConfig) agentEvidenceInterval() (time.Duration, error) { return interval, nil } -func (ac *apiConfig) validate() error { - if ac == nil { - return fmt.Errorf("no api config specified in config") - } - - if strings.TrimSpace(ac.Url) == "" { - return fmt.Errorf("api url must be configured") - } - - if ac.hasPartialAuth() { - return fmt.Errorf("api auth requires both client_id and client_secret when configured") - } - - if ac.hasAuth() { - if _, err := uuid.Parse(strings.TrimSpace(ac.Auth.ClientID)); err != nil { - return fmt.Errorf("api auth client_id must be a valid UUID") - } - } - - return nil -} - func (ac *apiConfig) hasAuth() bool { return ac != nil && ac.Auth != nil && @@ -201,7 +190,21 @@ const AgentPolicyDir = ".compliance-framework/policies" const DefaultProtocolVersion int32 = 1 const RunnerV2ProtocolVersion int32 = 2 const AnnotationProtocolVersionKey = "org.ccf.plugin.protocol.version" -const daemonCronStopTimeout = 30 * time.Second + +// CCFPropNamespace is the OSCAL prop namespace of CCF props. +const CCFPropNamespace = "https://compliance-framework.github.io/ns" + +// configRevisionPropName stamps evidence with the applied remote configuration revision (R38). +const configRevisionPropName = "agent-config-revision" + +// daemonCronStopTimeout bounds the cron stop on SIGINT/SIGTERM before plugins are killed, +// also when the signal arrives during a reload drain (R33). +var daemonCronStopTimeout = 30 * time.Second + +// reloadDrainTimeout bounds how long in-flight plugin runs may finish when a new +// configuration replaces the running one (R33). SIGTERM keeps daemonCronStopTimeout. +var reloadDrainTimeout = 5 * time.Minute + const agentEvidenceErrorArtifactMaxBytes = 1024 * 1024 type pluginRunStatus string @@ -237,100 +240,15 @@ with plugins to ensure continuous compliance.`, } agentCmd.Flags().CountP("verbose", "v", "Enable verbose output") - viper.BindPFlag("verbose", agentCmd.Flags().Lookup("verbose")) - agentCmd.Flags().BoolP("daemon", "d", false, "Specify to run as a long running daemon") - viper.BindPFlag("daemon", agentCmd.Flags().Lookup("daemon")) agentCmd.Flags().StringP("config", "c", "", "Location of config file") agentCmd.MarkFlagRequired("config") - return agentCmd -} - -func mergeConfig(cmd *cobra.Command, fileConfig *viper.Viper) (*agentConfig, error) { - // For now, we are reading from a file. This will probably be updated to a remote source soon. - - // Daemon has a default false value, which will override all values passed through Viper. - // We need to check whether it was actually passed `Changed()`, and then merge its value into our config. - if cmd.Flags().Changed("daemon") { - isDaemon, err := cmd.Flags().GetBool("daemon") - if err != nil { - return nil, err - } + agentCmd.Flags().String("state-dir", "", "Directory for this instance's state (instance ID, remote config cache); overrides CCF_STATE_DIR. Default: .compliance-framework/state/") + agentCmd.Flags().String("instance-id", "", "Pin this instance's UUID (not persisted); overrides CCF_INSTANCE_ID") - err = fileConfig.MergeConfigMap(map[string]interface{}{ - "daemon": isDaemon, - }) - if err != nil { - return nil, err - } - } - - if cmd.Flags().Changed("verbose") { - verbosity, err := cmd.Flags().GetCount("verbose") - if err != nil { - return nil, err - } - err = fileConfig.MergeConfigMap(map[string]interface{}{ - "verbosity": verbosity, - }) - if err != nil { - return nil, err - } - } - - config := &agentConfig{} - err := fileConfig.Unmarshal(config) - - if err != nil { - return nil, err - } - - markExplicitPluginProtocols(fileConfig, config) - updateAllPluginProtocols(config) - - return config, nil -} - -func bindAgentEnv(config *viper.Viper) error { - for key, envVar := range map[string]string{ - "api.auth.client_id": "CCF_API_AUTH_CLIENT_ID", - "api.auth.client_secret": "CCF_API_AUTH_CLIENT_SECRET", - } { - if err := config.BindEnv(key, envVar); err != nil { - return err - } - } - - return nil -} - -func markExplicitPluginProtocols(fileConfig *viper.Viper, config *agentConfig) { - rawPlugins := fileConfig.GetStringMap("plugins") - for name, rawPlugin := range rawPlugins { - pluginConfig, ok := config.Plugins[name] - if rawPlugin == nil { - if config.Plugins == nil { - config.Plugins = map[string]*agentPlugin{} - } - if !ok { - config.Plugins[name] = nil - } - continue - } - - if !ok || pluginConfig == nil { - continue - } - - pluginMap, ok := rawPlugin.(map[string]interface{}) - if !ok { - continue - } - - _, pluginConfig.protocolSet = pluginMap["protocol_version"] - } + return agentCmd } func updateAllPluginProtocols(agentConfig *agentConfig) { @@ -412,91 +330,99 @@ func configureRunner(name string, runnerInstance runner.RunnerV2, config agentPl return err } -func loadConfig(cmd *cobra.Command, v *viper.Viper) (*agentConfig, error) { - err := v.ReadInConfig() - if err != nil { - return nil, err - } - - config, err := mergeConfig(cmd, v) - if err != nil { - return nil, err - } - - err = config.validate() - if err != nil { - return nil, err - } - return config, nil -} - // Main the entrypoint for the `agent` command // // It will read the configuration file, and then run the agent. Various command line flags can // be used to override the config file. func agentRunner(cmd *cobra.Command, args []string) error { - configPath := cmd.Flag("config").Value.String() - - if !path.IsAbs(configPath) { - workDir, err := os.Getwd() - if err != nil { - return err - } - configPath = path.Join(workDir, configPath) - } - - v := viper.New() - v.SetConfigFile(configPath) - v.SetEnvPrefix("CCF") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() - if err := bindAgentEnv(v); err != nil { - return err - } - logger := hclog.New(&hclog.LoggerOptions{ Name: "agent", Output: os.Stdout, Level: hclog.Debug, }) - agentRun := NewAgentRunner() - - ctx, configCancel := context.WithCancel(context.Background()) - defer configCancel() - - v.OnConfigChange(func(in fsnotify.Event) { - // We want to wait for any running agent processes to finish first. - logger.Debug("config file changed", "path", in.Name) - configCancel() - }) - v.WatchConfig() + configPath, err := filepath.Abs(cmd.Flag("config").Value.String()) + if err != nil { + return err + } - // For the daemon, we run the agent continuously. - // It will exit as soon as the config changes, and then start again with new configs set. - for { - ctx, configCancel = context.WithCancel(context.Background()) - config, err := loadConfig(cmd, v) + stateDir, stateDirSource, err := stateDirFrom(cmd, configPath) + if err != nil { + return err + } + idOverride, err := instanceIDOverride(cmd) + if err != nil { + return err + } + store := agentstate.Open(stateDir, logger) + id, persisted := store.InstanceID(idOverride) + // R52: the default state dir depends on the absolute config path, so moving the config + // file silently creates a new instance. Say where state lives. + logger.Info("Agent state", "state_dir", stateDir, "state_dir_source", stateDirSource, "instance_id", id.String(), "instance_id_persisted", persisted) + + ar := NewAgentRunner(WithInstanceID(id)) + rc := newReconciler(cmd, configPath, store, ar, logger) + rc.instanceID = id + pluginLibs := &pluginlib.Cache{} + rc.pluginLib = func(ctx context.Context, source string) (string, error) { + binary, err := ar.downloadPlugin(ctx, source, logger) if err != nil { - logger.Error("Error loading new config", "error", err) - panic(err) + return "", err } - agentRun.UpdateConfig(config) - err = agentRun.Run(ctx) + return pluginLibs.Version(binary) + } + rc.onStartupFailure = ar.ReportStartupFailure - if err != nil { - logger.Error("Error running agent", "error", err) - os.Exit(1) - } + active, err := rc.startup(context.Background()) + if err != nil { + // An unusable local configuration at startup exits 1, as it always has. + return err + } - if !config.Daemon { - break - } + rootCtx, stopLoop := context.WithCancel(context.Background()) + defer stopLoop() + if active.runtime.Daemon { + defer rc.watchFile()() + go rc.loop(rootCtx) } - configCancel() + return rc.run(active, func(ctx context.Context, cfg *agentConfig) error { + ar.UpdateConfig(cfg) + return ar.Run(ctx) + }) +} - return nil +// stateDirFrom resolves the state directory: --state-dir, then CCF_STATE_DIR, then the +// default derived from the absolute config path (R31). It also returns where it came from. +func stateDirFrom(cmd *cobra.Command, configPath string) (dir string, source string, err error) { + if flag := cmd.Flags().Lookup("state-dir"); flag != nil && strings.TrimSpace(flag.Value.String()) != "" { + dir, err = filepath.Abs(strings.TrimSpace(flag.Value.String())) + return dir, "flag", err + } + if env := strings.TrimSpace(os.Getenv("CCF_STATE_DIR")); env != "" { + dir, err = filepath.Abs(env) + return dir, "env", err + } + dir, err = agentstate.DefaultDir(configPath) + return dir, "default(config-path)", err +} + +// instanceIDOverride returns --instance-id or CCF_INSTANCE_ID. An override that is not a +// UUID is an error: silently ignoring it would register a different instance. +func instanceIDOverride(cmd *cobra.Command) (string, error) { + value, source := "", "" + if flag := cmd.Flags().Lookup("instance-id"); flag != nil && strings.TrimSpace(flag.Value.String()) != "" { + value, source = strings.TrimSpace(flag.Value.String()), "--instance-id" + } else if env := strings.TrimSpace(os.Getenv("CCF_INSTANCE_ID")); env != "" { + value, source = env, "CCF_INSTANCE_ID" + } + if value == "" { + return "", nil + } + if _, err := uuid.Parse(value); err != nil { + return "", fmt.Errorf("%s must be a UUID: %w", source, err) + } + return value, nil } type AgentRunner struct { @@ -514,34 +440,68 @@ type AgentRunner struct { downloadGroup singleflight.Group fetchAnnotations func(ctx context.Context, source string, option ...remote.Option) (map[string]string, error) runPluginFunc func(ctx context.Context, name string, pluginConfig *agentPlugin) error + sendHeartbeatFunc func(ctx context.Context, instanceID uuid.UUID) error + // notifySignals and exitFunc are test seams over signal.Notify(SIGINT, SIGTERM) and + // os.Exit (nil = the real ones). + notifySignals func(c chan<- os.Signal) + exitFunc func(code int) pluginRunMu sync.RWMutex pluginRuns map[string]pluginRunRecord firstAgentEvidenceSendStarted bool - queryBundles []*rego.Rego + // instanceID is this agent instance's stable ID (R31); set once at construction. + instanceID uuid.UUID + + // protocolCache maps a plugin source to the protocol version its OCI annotations + // declared. It survives reloads so a registry outage during a reload cannot silently + // turn a v2 plugin into v1 (R32). + protocolCacheMu sync.Mutex + protocolCache map[string]int32 } -func NewAgentRunner() *AgentRunner { - return &AgentRunner{ +// AgentRunnerOption configures an AgentRunner. +type AgentRunnerOption func(*AgentRunner) + +// WithInstanceID sets the instance ID the heartbeat and config reports use. +func WithInstanceID(id uuid.UUID) AgentRunnerOption { + return func(ar *AgentRunner) { ar.instanceID = id } +} + +func NewAgentRunner(opts ...AgentRunnerOption) *AgentRunner { + ar := &AgentRunner{ pluginLocations: map[string]string{}, policyLocations: map[string]string{}, activePluginClients: map[*plugin.Client]struct{}{}, pluginRuns: map[string]pluginRunRecord{}, fetchAnnotations: internal.GetAnnotations, httpClient: http.DefaultClient, + instanceID: uuid.New(), + protocolCache: map[string]int32{}, + } + for _, opt := range opts { + opt(ar) } + return ar } +// InstanceID returns the instance ID. +func (ar *AgentRunner) InstanceID() uuid.UUID { return ar.instanceID } + func (ar *AgentRunner) UpdateConfig(config *agentConfig) { logger := hclog.New(&hclog.LoggerOptions{ Name: "agent-runner", Output: os.Stdout, Level: hclog.Level(config.logVerbosity()), }) - client := ar.buildAPIClient(config, logger) ar.stateMu.Lock() + // Reuse the SDK client when the api block is unchanged, so its token cache survives + // overlay reloads. + client := ar.apiClient + if client == nil || ar.config == nil || !reflect.DeepEqual(ar.config.ApiConfig, config.ApiConfig) { + client = ar.buildAPIClient(config, logger) + } ar.config = config ar.logger = logger ar.apiClient = client @@ -1055,26 +1015,43 @@ func (ar *AgentRunner) Run(ctx context.Context) error { } logger.Debug("Pessimistically downloading plugins and policies worked successfully. Starting the agent.") - if config.Daemon == true { - ar.runDaemon(ctx) - return nil + if config.Daemon { + return ar.runDaemon(ctx) } return ar.runAllPlugins(ctx) } func (ar *AgentRunner) resolvePluginProtocols(ctx context.Context) { + ar.resolveProtocolsFor(ctx, ar.getConfig(), ar.getLogger()) +} + +// resolveProtocolsFor sets the protocol version of every implicit-protocol OCI plugin in config +// from its annotations, consulting protocolCache first. Only a cache miss fetches annotations. +func (ar *AgentRunner) resolveProtocolsFor(ctx context.Context, config *agentConfig, logger hclog.Logger) { if ctx == nil { ctx = context.Background() } + if config == nil { + return + } + if logger == nil { + logger = hclog.NewNullLogger() + } - config := ar.getConfig() - logger := ar.getLogger() for pluginName, pluginConfig := range config.Plugins { if pluginConfig == nil || pluginConfig.protocolSet || !internal.IsOCI(pluginConfig.Source) { continue } + ar.protocolCacheMu.Lock() + cached, hit := ar.protocolCache[pluginConfig.Source] + ar.protocolCacheMu.Unlock() + if hit { + pluginConfig.ProtocolVersion = cached + continue + } + func() { annotationCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -1097,32 +1074,37 @@ func (ar *AgentRunner) resolvePluginProtocols(ctx context.Context) { } pluginConfig.ProtocolVersion = protocolVersion + ar.protocolCacheMu.Lock() + ar.protocolCache[pluginConfig.Source] = protocolVersion + ar.protocolCacheMu.Unlock() }() } } -// Should never return, either handles any error or panics. -func (ar *AgentRunner) runDaemon(ctx context.Context) { +// runDaemon runs the plugin crons until ctx is cancelled (a reload: in-flight runs drain for +// up to reloadDrainTimeout, R33) or the process receives SIGINT/SIGTERM (exit). Setup errors +// are returned rather than exiting the process. +func (ar *AgentRunner) runDaemon(ctx context.Context) error { logger := ar.getLogger() sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + ar.signalNotify(sigs) defer signal.Stop(sigs) agentCron, err := ar.setupCron(ctx) if err != nil { logger.Error("Error setting up agent cron", "error", err) - os.Exit(1) + return err } heartbeatCron, err := ar.setupHeartbeatCron(ctx) if err != nil { logger.Error("Error setting up heartbeat", "error", err) - os.Exit(1) + return err } agentEvidenceCron, err := ar.setupAgentEvidenceCron(ctx) if err != nil { logger.Error("Error setting up agent evidence", "error", err) - os.Exit(1) + return err } // Start the cron and notify readiness @@ -1150,23 +1132,59 @@ func (ar *AgentRunner) runDaemon(ctx context.Context) { logger.Debug("Shutting down plugins") ar.closePluginClients() logger.Debug("Exiting") - os.Exit(0) + ar.exitProcess(0) + return nil case <-ctx.Done(): logger.Debug("received cancel signal to return from daemon") logger.Debug("Stopping crons") agentCronStopCtx := agentCron.Stop() heartbeatCronStopCtx := heartbeatCron.Stop() agentEvidenceCronStopCtx := agentEvidenceCron.Stop() - if !waitForCronStop(daemonCronStopTimeout, agentCronStopCtx, heartbeatCronStopCtx, agentEvidenceCronStopCtx) { - logger.Warn("Timed out waiting for cron jobs to stop before plugin cleanup", "timeout", daemonCronStopTimeout) + drained, sig := waitForCronStopOrSignal(reloadDrainTimeout, sigs, agentCronStopCtx, heartbeatCronStopCtx, agentEvidenceCronStopCtx) + if sig != nil { + // A SIGINT/SIGTERM during the reload drain is not lost: it keeps its 30s (R33). + logger.Info("received signal during the reload drain; terminating plugins and exiting", "signal", sig) + if !waitForCronStop(daemonCronStopTimeout, agentCronStopCtx, heartbeatCronStopCtx, agentEvidenceCronStopCtx) { + logger.Warn("Timed out waiting for cron jobs to stop before plugin cleanup", "timeout", daemonCronStopTimeout) + } + ar.closePluginClients() + logger.Debug("Exiting") + ar.exitProcess(0) + return nil + } + if !drained { + logger.Warn("Timed out waiting for in-flight plugin runs to drain before reload", "timeout", reloadDrainTimeout) } logger.Debug("Shutting down plugins") ar.closePluginClients() + return nil + } +} + +func (ar *AgentRunner) signalNotify(c chan<- os.Signal) { + if ar.notifySignals != nil { + ar.notifySignals(c) return } + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) +} + +func (ar *AgentRunner) exitProcess(code int) { + if ar.exitFunc != nil { + ar.exitFunc(code) + return + } + os.Exit(code) } func waitForCronStop(timeout time.Duration, stopContexts ...context.Context) bool { + done, _ := waitForCronStopOrSignal(timeout, nil, stopContexts...) + return done +} + +// waitForCronStopOrSignal waits until every stop context is done (true), the timeout expires +// (false) or a signal arrives on sigs (false and the signal; a nil sigs never fires). +func waitForCronStopOrSignal(timeout time.Duration, sigs <-chan os.Signal, stopContexts ...context.Context) (bool, os.Signal) { allDone := make(chan struct{}) waitCtx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1194,13 +1212,15 @@ func waitForCronStop(timeout time.Duration, stopContexts ...context.Context) boo select { case <-allDone: - return true + return true, nil + case sig := <-sigs: + return false, sig case <-timer.C: select { case <-allDone: - return true + return true, nil default: - return false + return false, nil } } } @@ -1240,9 +1260,13 @@ func (ar *AgentRunner) setupHeartbeatCron(ctx context.Context) (*cron.Cron, erro c := cron.New(cron.WithParser(cron.NewParser( cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, ))) - staticAgentUUID := uuid.New() + staticAgentUUID := ar.instanceID + sendHeartbeat := ar.SendHeartbeat + if ar.sendHeartbeatFunc != nil { + sendHeartbeat = ar.sendHeartbeatFunc + } _, err := c.AddFunc(fmt.Sprintf("%d * * * * *", staggeredSeconds), func() { - err := ar.SendHeartbeat(ctx, staticAgentUUID) + err := sendHeartbeat(ctx, staticAgentUUID) if err != nil { logger.Error("Failed to send heartbeat", "error", err, "uuid", staticAgentUUID.String()) } @@ -1291,10 +1315,18 @@ func (ar *AgentRunner) setupCron(ctx context.Context) (*cron.Cron, error) { parserOptions, ))) config := ar.getConfig() - runPlugin := ar.runPlugin + // Each job runs with the config, API client and logger of THIS setup: a job still running + // when a reload's drain times out must not pick up the next configuration's state. + snap := ar.snapshot() + runPlugin := func(ctx context.Context, name string, plugin *agentPlugin) error { + return ar.runPluginWith(ctx, snap, name, plugin) + } if ar.runPluginFunc != nil { runPlugin = ar.runPluginFunc } + // Plugin runs are not cut short by a reload: runDaemon stops the cron and lets in-flight + // runs drain for up to reloadDrainTimeout (R33) before killing the plugin processes. + jobCtx := context.WithoutCancel(ctx) for pluginName, pluginConfig := range config.Plugins { currentPluginName := pluginName @@ -1309,14 +1341,14 @@ func (ar *AgentRunner) setupCron(ctx context.Context) (*cron.Cron, error) { jobLogger := logger.With("plugin", currentPluginName, "schedule", schedule) job := cron.NewChain(cron.SkipIfStillRunning(cronLogger{logger: jobLogger})).Then(cron.FuncJob(func() { ar.markPluginRunStarted(currentPluginName) - err := runPlugin(ctx, currentPluginName, currentPluginConfig) + err := runPlugin(jobCtx, currentPluginName, currentPluginConfig) ar.markPluginRunFinished(currentPluginName, err) if err != nil { // TODO how will we handle these errors ? jobLogger.Error("Error running plugin", "error", err, "protocol_version", currentPluginConfig.ProtocolVersion) } if ar.reserveFirstAgentEvidenceSend() { - if evidenceErr := ar.SendAgentRunEvidence(ctx); evidenceErr != nil { + if evidenceErr := ar.SendAgentRunEvidence(jobCtx); evidenceErr != nil { ar.releaseFirstAgentEvidenceSend() jobLogger.Error("Failed to send agent run evidence", "error", evidenceErr) } @@ -1424,6 +1456,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths), runner.WithSources(sourceOf(pluginConfig.Source, source), policySources), + runner.WithEvidenceProps(configRevisionProps(config)...), ) policyBehaviorProto := policyBehaviorToProto(pluginConfig.PolicyBehavior) @@ -1487,15 +1520,23 @@ func (ar *AgentRunner) sendAgentRunEvidenceOnStartupFailure(ctx context.Context) return ar.SendAgentRunEvidence(ctx) } -// Run the agent as an instance, this is a single run of the agent that will check the -// policies against the plugins. +// runSnapshot is the state one plugin run uses from start to end. +type runSnapshot struct { + config *agentConfig + client *sdk.Client + logger hclog.Logger +} + +func (ar *AgentRunner) snapshot() runSnapshot { + return runSnapshot{config: ar.getConfig(), client: ar.getAPIClient(), logger: ar.getLogger()} +} + +// runPluginWith runs one plugin once with the state in snap. // // Returns: // - error: any error that occurred during the run -func (ar *AgentRunner) runPlugin(ctx context.Context, name string, plugin *agentPlugin) error { - config := ar.getConfig() - client := ar.getAPIClient() - logger := ar.getLogger() +func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name string, plugin *agentPlugin) error { + config, client, logger := snap.config, snap.client, snap.logger logger.Debug("Running single plugin with shared API SDK client", "plugin", name, "auth_enabled", hasAPIAuth(config), @@ -1560,6 +1601,7 @@ func (ar *AgentRunner) runPlugin(ctx context.Context, name string, plugin *agent resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), runner.WithSources(sourceOf(plugin.Source, pluginExecutable), policySources), + runner.WithEvidenceProps(configRevisionProps(config)...), ) policyBehaviorProto := policyBehaviorToProto(plugin.PolicyBehavior) @@ -1592,10 +1634,7 @@ func (ar *AgentRunner) SendHeartbeat(ctx context.Context, staticAgentUUID uuid.U ) heartbeatCtx, cancel := context.WithTimeout(ctx, time.Second*30) defer cancel() - err := client.Heartbeat.Create(heartbeatCtx, sdktypes.Heartbeat{ - UUID: staticAgentUUID, - CreatedAt: time.Now().UTC(), - }) + err := client.Heartbeat.Create(heartbeatCtx, buildHeartbeat(config, staticAgentUUID, time.Now().UTC())) if err != nil { logger.Error("Error sending heartbeat via SDK", "error", err, "uuid", staticAgentUUID.String()) return err @@ -1604,6 +1643,33 @@ func (ar *AgentRunner) SendHeartbeat(ctx context.Context, staticAgentUUID uuid.U return nil } +// configRevisionProps returns the evidence prop naming the applied overlay revision, or nil +// when the agent runs the file only (R38). +func configRevisionProps(config *agentConfig) []sdktypes.Property { + meta := config.syncInfo() + if meta.AppliedRevision <= 0 { + return nil + } + return []sdktypes.Property{{ + Ns: CCFPropNamespace, + Name: configRevisionPropName, + Value: strconv.FormatInt(meta.AppliedRevision, 10), + }} +} + +// buildHeartbeat builds the heartbeat body. When remote configuration is not off it carries +// the applied revision (0 when running the file only, never null) and the effective digest, +// which lets the API create the instance row (R11, R45). +func buildHeartbeat(config *agentConfig, id uuid.UUID, now time.Time) sdktypes.Heartbeat { + hb := sdktypes.Heartbeat{UUID: id, CreatedAt: now} + if meta := config.syncInfo(); meta.Mode != "" && meta.Mode != agentconfig.ModeOff { + rev := meta.AppliedRevision + hb.ConfigRevision = &rev + hb.ConfigDigest = meta.Digest + } + return hb +} + type agentEvidenceCreateRequest struct { sdktypes.Evidence BackMatter *oscalTypes_1_1_3.BackMatter `json:"back-matter,omitempty"` @@ -1712,6 +1778,7 @@ func (ar *AgentRunner) buildAgentRunEvidence(now time.Time) (*agentEvidenceCreat End: now, Expires: expires, Links: links, + Props: configRevisionProps(config), Status: sdktypes.ObjectiveStatus{ Reason: reason, Remarks: remarks, @@ -1839,12 +1906,23 @@ func safePluginErrorFilename(pluginName string) string { return b.String() + "-error.txt" } +// pluginCommand is the command that starts the plugin binary at path. Plugins get the host +// environment minus the agent's own API credentials (R26); go-plugin would otherwise append +// the whole environment. +func pluginCommand(path string) *exec.Cmd { + cmd := exec.Command(path) + cmd.Env = pluginEnviron(os.Environ()) + return cmd +} + func (ar *AgentRunner) getRunnerInstance(logger hclog.Logger, path string, protocolVersion int32) (runner.RunnerV2, func(), error) { // We're a host! Start by launching the plugin process. + cmd := pluginCommand(path) client := plugin.NewClient(&plugin.ClientConfig{ HandshakeConfig: runner.HandshakeConfig, Plugins: runner.PluginMap, - Cmd: exec.Command(path), + Cmd: cmd, + SkipHostEnv: true, Logger: logger, AllowedProtocols: []plugin.Protocol{plugin.ProtocolGRPC}, }) @@ -1944,6 +2022,110 @@ func (ar *AgentRunner) DownloadPolicies(ctx context.Context) error { return nil } +// pluginEnviron returns environ without the variables whose name starts with CCF_API_AUTH_ +// (case-insensitive), so plugins never see the agent's API credentials (R26). +func pluginEnviron(environ []string) []string { + out := make([]string, 0, len(environ)) + for _, kv := range environ { + name, _, _ := strings.Cut(kv, "=") + if strings.HasPrefix(strings.ToUpper(name), "CCF_API_AUTH_") { + continue + } + out = append(out, kv) + } + return out +} + +// Prefetch downloads every plugin and policy source of cfg and resolves plugin +// protocol versions, WITHOUT touching the running configuration's pluginLocations or +// policyLocations. The reconciler calls it before cancelling the running configuration, so a +// download failure never tears down a working agent (prepare-then-cancel, R32). +func (ar *AgentRunner) Prefetch(ctx context.Context, cfg *agentConfig) error { + logger := ar.getLogger() + if logger == nil { + logger = hclog.NewNullLogger() + } + pluginSources := map[string]struct{}{} + policySources := map[string]struct{}{} + for _, pluginConfig := range cfg.Plugins { + pluginSources[pluginConfig.Source] = struct{}{} + for _, policy := range pluginConfig.Policies { + policySources[string(policy)] = struct{}{} + } + } + for _, source := range slices.Sorted(maps.Keys(pluginSources)) { + if _, err := ar.downloadPlugin(ctx, source, logger); err != nil { + return &downloadError{source: source, err: err} + } + } + ar.resolveProtocolsFor(ctx, cfg, logger) + for _, source := range slices.Sorted(maps.Keys(policySources)) { + if _, err := ar.downloadPolicy(ctx, source, logger); err != nil { + return &downloadError{source: source, policy: true, err: err} + } + } + return nil +} + +// downloadError is a Prefetch failure: which plugin or policy source could not be fetched. +type downloadError struct { + source string + policy bool + err error +} + +func (e *downloadError) Error() string { + kind := "plugin" + if e.policy { + kind = "policy" + } + return fmt.Sprintf("download %s %s: %v", kind, e.source, e.err) +} + +func (e *downloadError) Unwrap() error { return e.err } + +// ReportStartupFailure records that the configuration the agent starts with could not be +// downloaded, exactly as Run always has on a startup download failure: the plugins using the +// failed source are marked failed and the startup-failure agent evidence is sent (when agent +// evidence and emit_on_run_completion are enabled). The agent then exits 1. +func (ar *AgentRunner) ReportStartupFailure(ctx context.Context, cfg *agentConfig, err error) { + ar.UpdateConfig(cfg) + var dl *downloadError + switch { + case errors.As(err, &dl) && dl.policy: + ar.markPluginsWithPolicyFailed(agentPolicy(dl.source), dl.err) + case errors.As(err, &dl): + ar.markPluginsWithSourceFailed(dl.source, dl.err) + } + logger := ar.getLogger() + logger.Error("Error downloading plugins and policies", "error", err) + if evidenceErr := ar.sendAgentRunEvidenceOnStartupFailure(ctx); evidenceErr != nil { + logger.Error("Error sending agent run evidence", "error", evidenceErr) + } +} + +// downloadPlugin returns the plugin binary of source for this platform, downloading it into +// the shared plugin cache when it is not there yet. Prefetch uses it, so the plugins report +// (R76) reads the agent library version from the binary Prefetch fetched. +func (ar *AgentRunner) downloadPlugin(ctx context.Context, source string, logger hclog.Logger) (string, error) { + if logger == nil { + logger = hclog.NewNullLogger() + } + platform := v1.Platform{ + Architecture: runtime.GOARCH, + OS: runtime.GOOS, + } + return ar.download(ctx, source, AgentPluginDir, "plugin", platformDownloadKey(platform), logger, remote.WithPlatform(platform)) +} + +// downloadPolicy fetches one policy source into the shared policy cache. +func (ar *AgentRunner) downloadPolicy(ctx context.Context, source string, logger hclog.Logger) (string, error) { + if logger == nil { + logger = hclog.NewNullLogger() + } + return ar.download(ctx, source, AgentPolicyDir, "policies", "", logger) +} + func platformDownloadKey(platform v1.Platform) string { return strings.Join([]string{platform.OS, platform.Architecture, platform.Variant}, "/") } diff --git a/cmd/agent_test.go b/cmd/agent_test.go index 5d72fd6..b04ab13 100644 --- a/cmd/agent_test.go +++ b/cmd/agent_test.go @@ -17,10 +17,12 @@ import ( "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/agent/runner/proto" + "github.com/compliance-framework/api/pkg/agentconfig" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/uuid" "github.com/hashicorp/go-hclog" hplugin "github.com/hashicorp/go-plugin" + "github.com/spf13/cobra" "github.com/spf13/viper" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -186,15 +188,8 @@ plugins: t.Fatalf("Error reading config: %v", err) } - config := &agentConfig{} - err = v.Unmarshal(config) - if err != nil { - t.Fatalf("Error unmarshalling config: %v", err) - } - markExplicitPluginProtocols(v, config) - updateAllPluginProtocols(config) - - if err = config.validate(); (err == nil) != test.valid { + _, err = baseFromViper(AgentCmd(), v, []byte(test.configYamlContent)) + if (err == nil) != test.valid { t.Errorf("Expected validity of config to be %v, got %v", test.valid, err) } }) @@ -340,17 +335,12 @@ plugins: t.Fatalf("Error reading config: %v", err) } - config, err := mergeConfig(AgentCmd(), v) - if err != nil { - t.Fatalf("Error merging config: %v", err) - } - - err = config.validate() + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatal("expected validate to fail when only one api auth env var is set") } - if err.Error() != "api auth requires both client_id and client_secret when configured" { - t.Fatalf("expected validate error %q, got %q", "api auth requires both client_id and client_secret when configured", err.Error()) + if err.Error() != "/api/auth: api auth requires both client_id and client_secret when configured" { + t.Fatalf("expected validate error %q, got %q", "/api/auth: api auth requires both client_id and client_secret when configured", err.Error()) } }) } @@ -452,17 +442,12 @@ func TestMergeConfig_RejectsUnsupportedExplicitProtocolVersion(t *testing.T) { t.Fatalf("Error reading config: %v", err) } - config, err := mergeConfig(AgentCmd(), v) - if err != nil { - t.Fatalf("Error merging config: %v", err) - } - - err = config.validate() + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatalf("Expected config validation to fail for unsupported protocol version") } - expected := "plugin plugin-with-invalid-version has unsupported protocol_version=100; supported values are 1 and 2" + expected := "/plugins/plugin-with-invalid-version/protocol_version: must be 1 or 2 (0 or unset = auto)" if err.Error() != expected { t.Fatalf("Expected error %q, got %q", expected, err.Error()) } @@ -476,12 +461,7 @@ func TestMergeConfig_RejectsExplicitZeroProtocolVersion(t *testing.T) { t.Fatalf("Error reading config: %v", err) } - config, err := mergeConfig(AgentCmd(), v) - if err != nil { - t.Fatalf("Error merging config: %v", err) - } - - err = config.validate() + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatalf("Expected config validation to fail for explicit zero protocol version") } @@ -500,17 +480,12 @@ func TestMergeConfig_RejectsNullPluginConfiguration(t *testing.T) { t.Fatalf("Error reading config: %v", err) } - config, err := mergeConfig(AgentCmd(), v) - if err != nil { - t.Fatalf("Error merging config: %v", err) - } - - err = config.validate() + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatalf("Expected config validation to fail for null plugin configuration") } - expected := "plugin null-plugin has null configuration" + expected := "/plugins/null-plugin: plugin \"null-plugin\" has no configuration" if err.Error() != expected { t.Fatalf("Expected error %q, got %q", expected, err.Error()) } @@ -2056,7 +2031,7 @@ func TestAgentEvidenceConfigDefaultsAndValidation(t *testing.T) { config := &agentConfig{ ApiConfig: &apiConfig{Url: "http://localhost:8080"}, } - if err := config.validate(); err != nil { + if err := validateRuntimeForTest(config); err != nil { t.Fatalf("expected no-plugin config to be valid: %v", err) } if !config.agentEvidenceEnabled() { @@ -2074,11 +2049,36 @@ func TestAgentEvidenceConfigDefaultsAndValidation(t *testing.T) { } config.AgentEvidence = &agentEvidenceConfig{Interval: "not-a-duration"} - if err := config.validate(); err == nil { + if err := validateRuntimeForTest(config); err == nil { t.Fatalf("expected invalid interval to fail validation") } } +// mergeConfig decodes the declared config from v and converts it to the runtime form without +// validating it (the old mergeConfig contract, kept for these tests). +func mergeConfig(cmd *cobra.Command, v *viper.Viper) (*agentConfig, error) { + declared, err := declaredFromViper(cmd, v) + if err != nil { + return nil, err + } + return toRuntime(declared, nil) +} + +// validateRuntimeForTest validates the declared equivalent of a runtime config. +func validateRuntimeForTest(config *agentConfig) error { + declared := agentconfig.Config{Daemon: config.Daemon, Verbosity: config.Verbosity} + if config.ApiConfig != nil { + declared.API = &agentconfig.APIConfig{URL: config.ApiConfig.Url} + if config.ApiConfig.Auth != nil { + declared.API.Auth = &agentconfig.APIAuth{ClientID: config.ApiConfig.Auth.ClientID, ClientSecret: config.ApiConfig.Auth.ClientSecret} + } + } + if config.AgentEvidence != nil { + declared.AgentEvidence = &agentconfig.EvidenceConfig{Enabled: config.AgentEvidence.Enabled, EmitOnRunCompletion: config.AgentEvidence.EmitOnRunCompletion, Interval: config.AgentEvidence.Interval} + } + return declared.Validate() +} + func newTestAgentConfig(baseURL string, auth *apiAuthConfig) *agentConfig { return &agentConfig{ ApiConfig: &apiConfig{ @@ -2165,3 +2165,27 @@ func jsonResponse(statusCode int, body string) *http.Response { Header: make(http.Header), } } + +// TestPluginCommandStripsAPICredentials: plugins get the host environment without the agent's +// API credentials (R26). +func TestPluginCommandStripsAPICredentials(t *testing.T) { + t.Setenv("CCF_API_AUTH_CLIENT_SECRET", "s3cret") + t.Setenv("ccf_api_auth_client_id", "id") + t.Setenv("CCF_PLUGIN_SETTING", "kept") + + cmd := pluginCommand("/bin/plugin") + if cmd.Path != "/bin/plugin" || cmd.Dir != "" { + t.Fatalf("path = %q, dir = %q", cmd.Path, cmd.Dir) + } + var kept bool + for _, kv := range cmd.Env { + name, _, _ := strings.Cut(kv, "=") + if strings.HasPrefix(strings.ToUpper(name), "CCF_API_AUTH_") { + t.Fatalf("the plugin must not see %s", name) + } + kept = kept || kv == "CCF_PLUGIN_SETTING=kept" + } + if !kept { + t.Fatal("other variables must be passed through") + } +} diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..75ea156 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,477 @@ +package cmd + +import ( + "bytes" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// baseSnapshot is one load of the local configuration: the file merged with CLI flags and +// bound environment variables. It is immutable once built. +// +// declared is the declared form (agentconfig.Config): it is what gets merged, classified, +// validated, redacted, digested and reported. The runtime form (*agentConfig) is built from +// it by toRuntime. +type baseSnapshot struct { + declared agentconfig.Config // file ⊕ CLI flags ⊕ bound env + raw []byte // exact bytes read (one read per load) + // envSourced are the JSON pointers of plugin leaves whose value came from a CCF_* env + // variable (R25). They are masked in reports and in the digest. + envSourced []string + // warnings are tolerated file-origin problems (R34): reported, never fatal. + warnings []agentconfig.FieldError + // skip holds the plugins dropped from the runtime because of a tolerated problem. + skip map[string]string + // fingerprint identifies the base for the rejected-revision memory. + fingerprint string +} + +// redactOpts is the single source of the masking options used for the reported base and +// effective documents AND for the effective digest (R55). +func (b *baseSnapshot) redactOpts() []agentconfig.RedactOption { + if b == nil || len(b.envSourced) == 0 { + return nil + } + return []agentconfig.RedactOption{agentconfig.WithMaskedPointers(b.envSourced...)} +} + +// toleratedFileRules are the validation rules whose failure is non-fatal when the value comes +// from the local file (R34). Today a bad file schedule only logs "Error adding plugin +// schedule" and the plugin never runs; everything else that fails validation fails startup. +// This list is closed: rules for new features never go here, because no existing file can +// depend on them. +var toleratedFileRules = []*regexp.Regexp{ + regexp.MustCompile(`^/plugins/[^/]+/schedule$`), +} + +func isToleratedFileRule(e agentconfig.FieldError) bool { + for _, re := range toleratedFileRules { + if re.MatchString(e.Path) { + return true + } + } + return false +} + +// isWarnOnlyFileRule reports whether a file-origin error is a warning that neither skips a +// plugin nor changes the value (R34, R51; owner review of agent#95). These are values that +// load on main with a meaning the agent keeps: +// - a negative verbosity: hclog.Info - v, i.e. a quieter agent (-1 = Warn); +// - a literal ${env:...} outside plugins.*.config (labels, policy_data, ...): an opaque +// string handed to the plugin or to Rego, never resolved. +func isWarnOnlyFileRule(e agentconfig.FieldError) bool { + return e.Path == "/verbosity" || e.Code == agentconfig.FieldCodeEnvLocation +} + +// newAgentViper builds a fresh viper for one load (R32): the watcher goroutine and the loader +// never share an instance. +func newAgentViper(configPath string) (*viper.Viper, error) { + ext := configExt(configPath) + v := viper.New() + v.SetConfigType(ext) + v.SetEnvPrefix("CCF") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + if err := bindAgentEnv(v); err != nil { + return nil, err + } + return v, nil +} + +func configExt(configPath string) string { + return strings.ToLower(strings.TrimPrefix(filepath.Ext(configPath), ".")) +} + +func bindAgentEnv(config *viper.Viper) error { + for key, envVar := range map[string]string{ + "api.auth.client_id": "CCF_API_AUTH_CLIENT_ID", + "api.auth.client_secret": "CCF_API_AUTH_CLIENT_SECRET", + // remote_config is set locally only (file, host env, CLI) (R30). Binding the mode lets + // Helm set it even when the file omits the block (G2.1). + "remote_config.mode": "CCF_REMOTE_CONFIG_MODE", + } { + if err := config.BindEnv(key, envVar); err != nil { + return err + } + } + + return nil +} + +// applyFlagOverrides merges CLI flags that were explicitly set into the viper config. +func applyFlagOverrides(cmd *cobra.Command, v *viper.Viper) error { + // Daemon has a default false value, which will override all values passed through Viper. + // We need to check whether it was actually passed `Changed()`, and then merge its value into our config. + if flag := cmd.Flags().Lookup("daemon"); flag != nil && flag.Changed { + isDaemon, err := cmd.Flags().GetBool("daemon") + if err != nil { + return err + } + if err := v.MergeConfigMap(map[string]interface{}{"daemon": isDaemon}); err != nil { + return err + } + } + + if flag := cmd.Flags().Lookup("verbose"); flag != nil && flag.Changed { + verbosity, err := cmd.Flags().GetCount("verbose") + if err != nil { + return err + } + if err := v.MergeConfigMap(map[string]interface{}{"verbosity": verbosity}); err != nil { + return err + } + } + return nil +} + +// declaredFromViper decodes the declared config from a viper that has read the file. It uses +// viper's default (weakly typed) decoder, exactly as the agent always has (R51): for example +// a YAML `false` plugin config value becomes "0" and a number becomes its decimal string. +func declaredFromViper(cmd *cobra.Command, v *viper.Viper) (agentconfig.Config, error) { + if err := applyFlagOverrides(cmd, v); err != nil { + return agentconfig.Config{}, err + } + + var declared agentconfig.Config + if err := v.Unmarshal(&declared); err != nil { + return agentconfig.Config{}, err + } + + markNullPlugins(v, &declared) + if err := checkExplicitZeroProtocol(v, declared); err != nil { + return agentconfig.Config{}, err + } + return declared, nil +} + +// markNullPlugins keeps `plugins: {x: null}` as a nil entry so validation rejects it, as it +// always has. +func markNullPlugins(v *viper.Viper, declared *agentconfig.Config) { + for name, rawPlugin := range v.GetStringMap("plugins") { + if rawPlugin != nil { + continue + } + if declared.Plugins == nil { + declared.Plugins = map[string]*agentconfig.Plugin{} + } + if _, ok := declared.Plugins[name]; !ok { + declared.Plugins[name] = nil + } + } +} + +// checkExplicitZeroProtocol rejects an explicit `protocol_version: 0` in the file (R9). In the +// declared form 0 means "auto", so the explicit value is only visible to viper. +func checkExplicitZeroProtocol(v *viper.Viper, declared agentconfig.Config) error { + names := make([]string, 0) + for name, rawPlugin := range v.GetStringMap("plugins") { + pluginMap, ok := rawPlugin.(map[string]interface{}) + if !ok { + continue + } + if _, set := pluginMap["protocol_version"]; !set { + continue + } + if p := declared.Plugins[name]; p != nil && p.ProtocolVersion == 0 { + names = append(names, name) + } + } + if len(names) == 0 { + return nil + } + slices.Sort(names) + return fmt.Errorf("plugin %s has unsupported protocol_version=0; supported values are %d and %d", names[0], DefaultProtocolVersion, RunnerV2ProtocolVersion) +} + +// envSourcedPointers returns the JSON pointers of plugin leaves whose value viper took from a +// CCF_* environment variable (R25). AutomaticEnv only overrides keys viper already knows (the +// file's keys), so checking the file's keys is exhaustive. +func envSourcedPointers(v *viper.Viper) []string { + var out []string + for _, key := range v.AllKeys() { + if !strings.HasPrefix(key, "plugins.") { + continue + } + envName := "CCF_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_")) + if _, ok := os.LookupEnv(envName); ok { + out = append(out, agentconfig.Pointer(strings.Split(key, ".")...)) + } + } + slices.Sort(out) + return out +} + +// loadBase reads and validates the local configuration. It builds a fresh viper per call +// (R32). A returned error means the file is unusable (fatal at startup; keep last-known-good +// on reload). Tolerated file problems (R34) are returned as warnings and skipped plugins. +func loadBase(cmd *cobra.Command, configPath string) (*baseSnapshot, error) { + raw, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + v, err := newAgentViper(configPath) + if err != nil { + return nil, err + } + if err := v.ReadConfig(bytes.NewReader(raw)); err != nil { + return nil, err + } + return baseFromViper(cmd, v, raw) +} + +// baseFromViper finishes loadBase on a viper that has read raw. +func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte) (*baseSnapshot, error) { + declared, err := declaredFromViper(cmd, v) + if err != nil { + return nil, err + } + + base := &baseSnapshot{ + declared: declared, + raw: raw, + envSourced: envSourcedPointers(v), + } + part := partitionByOrigin(declared.Validate(), nil) + if len(part.fatal) > 0 { + return nil, agentconfig.ValidationErrors(part.fatal) + } + base.warnings = part.warnings + base.skip = part.skip + base.fingerprint = agentconfig.Digest(declared, base.redactOpts()...) + return base, nil +} + +// validationPartition is the R34 split of a config's validation errors. +type validationPartition struct { + overlay []agentconfig.FieldError // touched by the overlay: strict + fatal []agentconfig.FieldError // file-origin, not tolerated: fatal + warnings []agentconfig.FieldError // file-origin, tolerated or warn-only: reported + skip map[string]string // plugin name -> reason, for tolerated (skip) errors +} + +// partitionByOrigin splits validation errors by origin (R34). An error at pointer P is +// overlay-origin when some overlay-touched pointer o equals P, is a prefix of P, or has P as a +// prefix (segment-wise). Everything else is file-origin: tolerated rules become warnings +// (and the plugin is skipped), warn-only rules become warnings (nothing is skipped or +// changed), the rest is fatal. +func partitionByOrigin(err error, overlayTouched []string) validationPartition { + var out validationPartition + if err == nil { + return out + } + var errs agentconfig.ValidationErrors + if !errors.As(err, &errs) { + out.fatal = []agentconfig.FieldError{{Path: "", Code: agentconfig.FieldCodeInvalidValue, Message: err.Error()}} + return out + } + for _, e := range errs { + switch { + case touchedByOverlay(e.Path, overlayTouched): + out.overlay = append(out.overlay, e) + case isToleratedFileRule(e): + out.warnings = append(out.warnings, e) + if segs := agentconfig.SplitPointer(e.Path); len(segs) >= 2 && segs[0] == "plugins" { + if out.skip == nil { + out.skip = map[string]string{} + } + out.skip[segs[1]] = e.Message + } + case isWarnOnlyFileRule(e): + out.warnings = append(out.warnings, e) + default: + out.fatal = append(out.fatal, e) + } + } + return out +} + +// touchedByOverlay compares pointers segment-wise in both directions. +func touchedByOverlay(ptr string, touched []string) bool { + p := agentconfig.SplitPointer(ptr) + for _, o := range touched { + t := agentconfig.SplitPointer(o) + n := min(len(p), len(t)) + if slices.Equal(p[:n], t[:n]) { + return true + } + } + return false +} + +// resolveEnv resolves ${env:NAME} placeholders in plugins.*.config values (R24) with the R60 +// file-origin leniency: when every unset variable of a value is already referenced by the +// base's (file) value at the same pointer, the value is passed to the plugin unchanged, as on +// main, and a warning is returned. An unset variable the overlay introduced still fails with +// agentconfig.ErrEnvMissing; forbidden names always fail with agentconfig.ErrEnvForbidden. +func resolveEnv(declared, base agentconfig.Config, lookup func(string) (string, bool)) (agentconfig.Config, []agentconfig.FieldError, error) { + type literal struct{ plugin, key, value string } + var keep []literal + var warnings []agentconfig.FieldError + work := declared + copied := map[string]bool{} // plugins whose Config was copied into work + for _, name := range slices.Sorted(maps.Keys(declared.Plugins)) { + p := declared.Plugins[name] + if p == nil { + continue + } + for _, key := range slices.Sorted(maps.Keys(p.Config)) { + value := p.Config[key] + names := agentconfig.EnvRefs(value) + if len(names) == 0 || slices.ContainsFunc(names, agentconfig.IsForbiddenEnvName) { + continue + } + var missing []string + for _, n := range names { + if _, ok := lookup(n); !ok { + missing = append(missing, n) + } + } + if len(missing) == 0 { + continue + } + fileRefs := agentconfig.EnvRefs(basePluginConfigValue(base, name, key)) + if slices.ContainsFunc(missing, func(n string) bool { return !slices.Contains(fileRefs, n) }) { + continue // overlay-introduced: ResolveEnv reports env-missing + } + if !copied[name] { + if len(copied) == 0 { + work.Plugins = maps.Clone(declared.Plugins) + } + cp := *p + cp.Config = maps.Clone(p.Config) + work.Plugins[name] = &cp + copied[name] = true + } + delete(work.Plugins[name].Config, key) + keep = append(keep, literal{name, key, value}) + warnings = append(warnings, agentconfig.FieldError{ + Path: agentconfig.Pointer("plugins", name, "config", key), + Code: agentconfig.FieldCodeEnvMissing, + Message: fmt.Sprintf("environment variable %s is not set; the value is passed to the plugin unchanged", strings.Join(missing, ", ")), + }) + } + } + resolved, err := agentconfig.ResolveEnv(work, lookup) + if err != nil { + return agentconfig.Config{}, nil, err + } + for _, l := range keep { + p := resolved.Plugins[l.plugin] + if p.Config == nil { + p.Config = map[string]string{} + } + p.Config[l.key] = l.value + } + return resolved, warnings, nil +} + +func basePluginConfigValue(base agentconfig.Config, plugin, key string) string { + if p := base.Plugins[plugin]; p != nil { + return p.Config[key] + } + return "" +} + +// toRuntime converts a merged, env-resolved declared config into the runtime structs. +// Disabled plugins and plugins named in skip (R34) are dropped: they get no cron, no download +// and no run state, but they stay in the declared form and in reports. +func toRuntime(c agentconfig.Config, skip map[string]string) (*agentConfig, error) { + out := &agentConfig{ + Daemon: c.Daemon, + Verbosity: c.Verbosity, + Plugins: map[string]*agentPlugin{}, + remote: c.EffectiveRemoteConfig(), + } + if c.API != nil { + out.ApiConfig = &apiConfig{Url: c.API.URL} + if c.API.Auth != nil { + out.ApiConfig.Auth = &apiAuthConfig{ + ClientID: c.API.Auth.ClientID, + ClientSecret: c.API.Auth.ClientSecret, + } + } + } + if c.AgentEvidence != nil { + out.AgentEvidence = &agentEvidenceConfig{ + Enabled: cloneBool(c.AgentEvidence.Enabled), + EmitOnRunCompletion: cloneBool(c.AgentEvidence.EmitOnRunCompletion), + Interval: c.AgentEvidence.Interval, + } + } + for name, p := range c.Plugins { + if p == nil { + return nil, fmt.Errorf("plugin %s has null configuration", name) + } + if !p.IsEnabled() { + continue + } + if _, skipped := skip[name]; skipped { + continue + } + rp := &agentPlugin{ + ProtocolVersion: p.ProtocolVersion, + protocolSet: p.ProtocolVersion != 0, + Source: p.Source, + Config: agentPluginConfig(copyStringMapKeepEmpty(p.Config)), + Labels: copyStringMapKeepEmpty(p.Labels), + PolicyData: p.PolicyData, + PolicyBehavior: p.PolicyBehavior, + } + if p.Schedule != nil { + s := *p.Schedule + rp.Schedule = &s + } + if p.Policies != nil { + rp.Policies = make([]agentPolicy, 0, len(p.Policies)) + for _, e := range p.Policies { + rp.Policies = append(rp.Policies, agentPolicy(e)) + } + } + out.Plugins[name] = rp + } + updateAllPluginProtocols(out) + return out, nil +} + +func cloneBool(b *bool) *bool { + if b == nil { + return nil + } + v := *b + return &v +} + +// copyStringMapKeepEmpty copies m, keeping nil as nil and empty as empty. +func copyStringMapKeepEmpty(m map[string]string) map[string]string { + if m == nil { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// validateAPIConfig validates a runtime api block with the shared rules (api.url required, +// both or neither credential, client_id a UUID). +func validateAPIConfig(config *apiConfig) error { + declared := agentconfig.Config{} + if config != nil { + declared.API = &agentconfig.APIConfig{URL: config.Url} + if config.Auth != nil { + declared.API.Auth = &agentconfig.APIAuth{ClientID: config.Auth.ClientID, ClientSecret: config.Auth.ClientSecret} + } + } + return declared.Validate() +} diff --git a/cmd/config_golden_test.go b/cmd/config_golden_test.go new file mode 100644 index 0000000..594d127 --- /dev/null +++ b/cmd/config_golden_test.go @@ -0,0 +1,144 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +// goldenHashFixtures are config files whose agentConfigurationHash was recorded BEFORE the +// declared/runtime config refactor (agent-remote-config G0). The hash feeds the `_agent` +// evidence label fallback and the agent evidence UUID, so it must stay byte-identical. +var goldenHashFixtures = []struct { + name string + yaml string + env map[string]string + hash string +}{ + { + name: "minimal", + yaml: ` +api: + url: http://localhost:8080 +`, + hash: "4f6b1c9d4fc55c1b99e6b9c60ef0b3783d6ca57fdccc4ca4a768a4256a72e842", + }, + { + name: "single plugin defaults", + yaml: ` +api: + url: http://localhost:8080 +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-ssh:v1 +`, + hash: "9513a410fcb588cbf62934306061dbc1c3c2a236b1727dacdfef7f02d110bb2a", + }, + { + name: "full plugin", + yaml: ` +daemon: true +verbosity: 1 +api: + url: http://localhost:8080 + auth: + client_id: 123e4567-e89b-12d3-a456-426614174000 + client_secret: s3cret +agent_evidence: + enabled: true + emit_on_run_completion: false + interval: 90m +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-ssh:v1 + schedule: "*/5 * * * *" + protocol_version: 2 + policies: + - ghcr.io/compliance-framework/plugin-ssh-policies:v1 + - ./local-policies + config: + host: 127.0.0.1 + port: 22 + collect_ip_allow_list: false + account_id: 123456789012 + labels: + team: platform + env: prod + policy_data: + max_auth_tries: 3 + nested: + allowed: [a, b] + policy_behavior: + deny: [warn] + github: + source: ghcr.io/compliance-framework/plugin-github:v1 + config: + token: plain-token +`, + hash: "b3bf4cf694f2aebeeac36762b1a7f4eb89288a9275b7994e99fb2f85183da1c2", + }, + { + name: "env sourced plugin config", + yaml: ` +api: + url: http://localhost:8080 +plugins: + github: + source: ghcr.io/compliance-framework/plugin-github:v1 + config: + token: from-file +`, + env: map[string]string{"CCF_PLUGINS_GITHUB_CONFIG_TOKEN": "from-env"}, + hash: "402b411f87e7d4ab32e3148317fc24e01e98347451b1e5af5bceaa5a29cc4175", + }, + { + name: "agent evidence disabled", + yaml: ` +api: + url: http://localhost:8080 +agent_evidence: + enabled: false +plugins: + a: + source: ./plugin-a + protocol_version: 1 + b: + source: ./plugin-b + schedule: "@hourly" +`, + hash: "40d4e852545aad49f8aad499df08051195b10b7c91cb1013c2bc19f6388ead82", + }, +} + +// loadGoldenFixture loads a fixture through the agent's file loader. It is the only line that +// changes when the loader is refactored. +func loadGoldenFixture(t *testing.T, yaml string) *agentConfig { + t.Helper() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + base, err := loadBase(AgentCmd(), path) + if err != nil { + t.Fatalf("load fixture: %v", err) + } + config, err := toRuntime(base.declared, base.skip) + if err != nil { + t.Fatalf("runtime fixture: %v", err) + } + return config +} + +func TestAgentConfigurationHashGolden(t *testing.T) { + for _, fx := range goldenHashFixtures { + t.Run(fx.name, func(t *testing.T) { + for k, v := range fx.env { + t.Setenv(k, v) + } + config := loadGoldenFixture(t, fx.yaml) + if got := agentConfigurationHash(config); got != fx.hash { + t.Fatalf("agentConfigurationHash changed: got %s want %s", got, fx.hash) + } + }) + } +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..7aa9d93 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,337 @@ +package cmd + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/hashicorp/go-hclog" + "google.golang.org/protobuf/proto" +) + +// writeConfigFile writes content to a temp file with the given extension and returns its path. +func writeConfigFile(t *testing.T, ext, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config."+ext) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func mustLoadBase(t *testing.T, ext, content string) *baseSnapshot { + t.Helper() + base, err := loadBase(AgentCmd(), writeConfigFile(t, ext, content)) + if err != nil { + t.Fatalf("loadBase: %v", err) + } + return base +} + +const weakTypedConfig = ` +api: + url: http://localhost:8080 +plugins: + aws: + source: ./plugin-aws + schedule: "0 * * * *" + config: + collect_ip_allow_list: false + account_id: 123456789012 + port: 22 + policy_data: + max_auth_tries: 3 + ratio: 0.5 + nested: + list: [1, 2] +` + +// TestLoadBase_WeakDecodingUnchanged checks R51: the file keeps viper's weak decoding exactly +// as today, with no warning and no skip. +func TestLoadBase_WeakDecodingUnchanged(t *testing.T) { + base := mustLoadBase(t, "yaml", weakTypedConfig) + if len(base.warnings) != 0 || len(base.skip) != 0 { + t.Fatalf("expected no warnings or skips, got %v %v", base.warnings, base.skip) + } + rt, err := toRuntime(base.declared, base.skip) + if err != nil { + t.Fatal(err) + } + want := agentPluginConfig{"collect_ip_allow_list": "0", "account_id": "123456789012", "port": "22"} + if got := rt.Plugins["aws"].Config; !reflect.DeepEqual(got, want) { + t.Fatalf("plugin config changed: got %#v want %#v", got, want) + } +} + +// TestWeakDecoding_SurvivesUnrelatedOverlay checks that an overlay touching only the schedule +// leaves the plugin's config and policy_data unchanged on the wire (R51). +func TestWeakDecoding_SurvivesUnrelatedOverlay(t *testing.T) { + base := mustLoadBase(t, "yaml", weakTypedConfig) + fileOnly, err := toRuntime(base.declared, nil) + if err != nil { + t.Fatal(err) + } + merged, err := agentconfig.Merge(base.declared, json.RawMessage(`{"plugins":{"aws":{"schedule":"*/5 * * * *"}}}`)) + if err != nil { + t.Fatal(err) + } + withOverlay, err := toRuntime(merged, nil) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(fileOnly.Plugins["aws"].Config, withOverlay.Plugins["aws"].Config) { + t.Fatalf("config changed by an unrelated overlay: %#v vs %#v", fileOnly.Plugins["aws"].Config, withOverlay.Plugins["aws"].Config) + } + a, err := mapToStruct(fileOnly.Plugins["aws"].PolicyData) + if err != nil { + t.Fatal(err) + } + b, err := mapToStruct(withOverlay.Plugins["aws"].PolicyData) + if err != nil { + t.Fatal(err) + } + if !proto.Equal(a, b) { + t.Fatalf("policy_data structpb differs: %v vs %v", a, b) + } + if got := *withOverlay.Plugins["aws"].Schedule; got != "*/5 * * * *" { + t.Fatalf("overlay schedule not applied: %q", got) + } +} + +func TestEnvSourcedPointers(t *testing.T) { + t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "from-env") + base := mustLoadBase(t, "yaml", ` +api: + url: http://localhost:8080 +plugins: + github: + source: ./plugin-github + config: + token: from-file + org: acme +`) + if want := []string{"/plugins/github/config/token"}; !reflect.DeepEqual(base.envSourced, want) { + t.Fatalf("envSourced = %v, want %v", base.envSourced, want) + } + if got := base.declared.Plugins["github"].Config["token"]; got != "from-env" { + t.Fatalf("expected env value to win, got %q", got) + } +} + +func TestLoadBase_BadFileScheduleIsTolerated(t *testing.T) { + base := mustLoadBase(t, "yaml", ` +api: + url: http://localhost:8080 +plugins: + ssh: + source: ./plugin-ssh + schedule: "not a cron" + github: + source: ./plugin-github +`) + if len(base.warnings) != 1 || base.warnings[0].Path != "/plugins/ssh/schedule" || base.warnings[0].Code != agentconfig.FieldCodeCron { + t.Fatalf("expected one cron warning, got %#v", base.warnings) + } + rt, err := toRuntime(base.declared, base.skip) + if err != nil { + t.Fatal(err) + } + if _, ok := rt.Plugins["ssh"]; ok { + t.Fatalf("expected ssh to be skipped") + } + if _, ok := rt.Plugins["github"]; !ok { + t.Fatalf("expected github to run") + } + if _, ok := base.declared.Plugins["ssh"]; !ok { + t.Fatalf("skipped plugin must stay in the declared (reported) config") + } +} + +// TestLoadBase_FileOriginWarnOnly pins R34/R51: values that load on main keep loading. They +// are reported as warnings, the value is unchanged and no plugin is skipped. +func TestLoadBase_FileOriginWarnOnly(t *testing.T) { + tests := []struct { + name string + content string + wantPath string + check func(t *testing.T, rt *agentConfig) + }{ + { + name: "negative verbosity", + content: "verbosity: -1\napi:\n url: http://localhost:8080\nplugins:\n ssh:\n source: ./plugin-ssh\n", + wantPath: "/verbosity", + check: func(t *testing.T, rt *agentConfig) { + if rt.Verbosity != -1 || rt.logVerbosity() != int32(hclog.Warn) { + t.Fatalf("verbosity -1 must stay Warn level, got %d", rt.Verbosity) + } + }, + }, + { + name: "literal env placeholder in labels", + content: "api:\n url: http://localhost:8080\nplugins:\n ssh:\n source: ./plugin-ssh\n labels:\n team: \"${env:TEAM}\"\n", + wantPath: "/plugins/ssh/labels/team", + check: func(t *testing.T, rt *agentConfig) { + if got := rt.Plugins["ssh"].Labels["team"]; got != "${env:TEAM}" { + t.Fatalf("the label must be passed through unchanged, got %q", got) + } + }, + }, + { + name: "literal env placeholder in policy_data", + content: "api:\n url: http://localhost:8080\nplugins:\n ssh:\n source: ./plugin-ssh\n policy_data:\n url: \"${env:URL}\"\n", + wantPath: "/plugins/ssh/policy_data/url", + check: func(t *testing.T, rt *agentConfig) { + if got := rt.Plugins["ssh"].PolicyData["url"]; got != "${env:URL}" { + t.Fatalf("policy_data must be passed through unchanged, got %v", got) + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base := mustLoadBase(t, "yaml", tt.content) + if len(base.warnings) != 1 || base.warnings[0].Path != tt.wantPath { + t.Fatalf("expected one warning at %s, got %#v", tt.wantPath, base.warnings) + } + if len(base.skip) != 0 { + t.Fatalf("a warn-only problem must not skip a plugin, got %v", base.skip) + } + rt, err := toRuntime(base.declared, base.skip) + if err != nil { + t.Fatal(err) + } + if _, ok := rt.Plugins["ssh"]; !ok { + t.Fatal("the plugin must run") + } + tt.check(t, rt) + }) + } + + t.Run("overlay-origin stays strict", func(t *testing.T) { + errs := agentconfig.ValidationErrors{ + {Path: "/verbosity", Code: agentconfig.FieldCodeInvalidValue, Message: "must not be negative"}, + {Path: "/plugins/ssh/labels/team", Code: agentconfig.FieldCodeEnvLocation, Message: "env"}, + } + p := partitionByOrigin(errs, []string{"/verbosity", "/plugins/ssh/labels/team"}) + if len(p.overlay) != 2 || len(p.warnings) != 0 { + t.Fatalf("overlay-introduced values must be strict, got %#v", p) + } + }) +} + +// TestLoadBase_LoadsAsOnMain: YAML that JSON cannot represent loads, and a key the agent does +// not know (here a leftover policy_bundles block) is ignored, as on main. +func TestLoadBase_LoadsAsOnMain(t *testing.T) { + base := mustLoadBase(t, "yaml", "api:\n url: http://localhost:8080\nplugins:\n ssh:\n source: ./plugin-ssh\n policy_data:\n ratio: .nan\n max: .inf\n") + if base.declared.Plugins["ssh"] == nil { + t.Fatalf("plugin ssh missing: %#v", base.declared.Plugins) + } + base = mustLoadBase(t, "yaml", "api:\n url: http://localhost:8080\npolicy_bundles:\n ssh:\n modules:\n a.rego: package a\nplugins:\n ssh:\n source: ./plugin-ssh\n") + if base.declared.Plugins["ssh"] == nil || len(base.warnings) != 0 { + t.Fatalf("an unknown key must be ignored: %#v %#v", base.declared.Plugins, base.warnings) + } +} + +func TestLoadBase_MissingAPIURLIsFatal(t *testing.T) { + _, err := loadBase(AgentCmd(), writeConfigFile(t, "yaml", ` +api: + auth: + client_id: 123e4567-e89b-12d3-a456-426614174000 + client_secret: s +plugins: + ssh: + source: ./plugin-ssh + schedule: "not a cron" +`)) + var verrs agentconfig.ValidationErrors + if !errors.As(err, &verrs) { + t.Fatalf("expected validation errors, got %v", err) + } + if len(verrs) != 1 || verrs[0].Path != "/api/url" { + t.Fatalf("expected only the api.url error to be fatal, got %v", verrs) + } +} + +func TestPartitionByOrigin(t *testing.T) { + errs := agentconfig.ValidationErrors{ + {Path: "/plugins/ssh/schedule", Code: agentconfig.FieldCodeCron, Message: "bad cron"}, + {Path: "/plugins/github/source", Code: agentconfig.FieldCodeRequired, Message: "source required"}, + } + t.Run("overlay touches another field of the same plugin", func(t *testing.T) { + p := partitionByOrigin(errs, []string{"/plugins/ssh/labels/team"}) + if len(p.overlay) != 0 || len(p.warnings) != 1 || len(p.fatal) != 1 { + t.Fatalf("unexpected partition %#v", p) + } + if _, ok := p.skip["ssh"]; !ok { + t.Fatalf("expected ssh skipped, got %v", p.skip) + } + }) + t.Run("overlay sets the schedule", func(t *testing.T) { + p := partitionByOrigin(errs, []string{"/plugins/ssh/schedule"}) + if len(p.overlay) != 1 || p.overlay[0].Path != "/plugins/ssh/schedule" || len(p.warnings) != 0 { + t.Fatalf("unexpected partition %#v", p) + } + }) + t.Run("overlay adds the plugin", func(t *testing.T) { + p := partitionByOrigin(errs, []string{"/plugins/ssh"}) + if len(p.overlay) != 1 { + t.Fatalf("a prefix pointer must make the error overlay-origin, got %#v", p) + } + }) + t.Run("segment-wise, not string-wise", func(t *testing.T) { + p := partitionByOrigin(errs, []string{"/plugins/ss"}) + if len(p.overlay) != 0 { + t.Fatalf("/plugins/ss must not match /plugins/ssh, got %#v", p) + } + }) +} + +func TestToRuntime_DisabledPluginDropped(t *testing.T) { + base := mustLoadBase(t, "yaml", ` +api: + url: http://localhost:8080 +plugins: + ssh: + source: ./plugin-ssh + enabled: false + github: + source: ./plugin-github +`) + rt, err := toRuntime(base.declared, nil) + if err != nil { + t.Fatal(err) + } + if _, ok := rt.Plugins["ssh"]; ok { + t.Fatalf("disabled plugin must not be in the runtime config") + } + if p := base.declared.Plugins["ssh"]; p == nil || p.IsEnabled() { + t.Fatalf("disabled plugin must stay declared, got %#v", p) + } +} + +func TestToRuntime_ProtocolVersion(t *testing.T) { + base := mustLoadBase(t, "yaml", ` +api: + url: http://localhost:8080 +plugins: + auto: + source: ./plugin-a + pinned: + source: ./plugin-b + protocol_version: 2 +`) + rt, err := toRuntime(base.declared, nil) + if err != nil { + t.Fatal(err) + } + if p := rt.Plugins["auto"]; p.protocolSet || p.ProtocolVersion != DefaultProtocolVersion { + t.Fatalf("auto plugin: %#v", p) + } + if p := rt.Plugins["pinned"]; !p.protocolSet || p.ProtocolVersion != RunnerV2ProtocolVersion { + t.Fatalf("pinned plugin: %#v", p) + } +} diff --git a/cmd/reconciler.go b/cmd/reconciler.go new file mode 100644 index 0000000..affddff --- /dev/null +++ b/cmd/reconciler.go @@ -0,0 +1,1044 @@ +package cmd + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/rand" + "os" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/compliance-framework/agent/internal/agentstate" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" + "github.com/fsnotify/fsnotify" + "github.com/google/uuid" + "github.com/hashicorp/go-hclog" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var ( + // remoteRequestTimeout bounds one config fetch or report; the startup fetch too. + remoteRequestTimeout = 30 * time.Second + // remoteAuthBackoff is the retry delay after a 404 (API without the feature) or a 401/403 + // on a config route (R8, R36). + remoteAuthBackoff = 10 * time.Minute + // reportConflictBackoff is the report pause after a 409 (per-agent instance cap, R36). + reportConflictBackoff = time.Hour + // reportResendInterval resends an unchanged report in case the API pruned or lost it. + reportResendInterval = 24 * time.Hour + // failedRetryMin / failedRetryMax bound the retry of a failed/* revision. + failedRetryMin = time.Minute + failedRetryMax = 10 * time.Minute + // prepareNetworkTimeout bounds the network step of prepare (plugin/policy prefetch), so a + // hung registry cannot stall the reconciler. A timeout is a failed/download-failed, which + // is retried with the failed backoff. + prepareNetworkTimeout = 5 * time.Minute +) + +// candidate is a complete, validated configuration that is ready to run. The reconciler builds +// it BEFORE cancelling the running configuration (prepare-then-cancel, R32). It is immutable +// once built. +type candidate struct { + base *baseSnapshot + overlay *agentstate.OverlayRecord // nil = file only + declared agentconfig.Config // merged, ${env:} NOT resolved: reported and digested + runtime *agentConfig // resolved, enabled-only, skipped plugins removed + digest string // agentconfig.Digest(declared, base.redactOpts()...) (R55) + // identity changes whenever anything that affects the runtime changes, including the + // values the digest masks or omits (api block, secrets). It never leaves the process. + identity string + warnings []agentconfig.FieldError // R34 file-origin warnings + // plugins are the runtime's plugins with their agent library versions (R76). + plugins []agentconfig.PluginReport +} + +// appliedRevision is the overlay revision the candidate applies, or nil for the file only. +func (c *candidate) appliedRevision() *int64 { + if c == nil || c.overlay == nil { + return nil + } + rev := c.overlay.Revision + return &rev +} + +// applyError is why a candidate could not be prepared. Status is agentconfig.StatusRejected +// or agentconfig.StatusFailed and Reason is one of agentconfig.Reasons. +type applyError struct { + Status string + Reason string + Err error + Unsafe []agentconfig.Change + // runtime is the prepared runtime of a download-failed candidate: startup hands it to + // onStartupFailure so the startup-failure evidence describes it, as on main. + runtime *agentConfig +} + +func (e *applyError) Error() string { + if e == nil { + return "" + } + if e.Err == nil { + return fmt.Sprintf("%s: %s", e.Status, e.Reason) + } + return fmt.Sprintf("%s: %s: %v", e.Status, e.Reason, e.Err) +} + +func (e *applyError) Unwrap() error { return e.Err } + +func rejected(reason string, err error) *applyError { + return &applyError{Status: agentconfig.StatusRejected, Reason: reason, Err: err} +} + +func failed(reason string, err error) *applyError { + return &applyError{Status: agentconfig.StatusFailed, Reason: reason, Err: err} +} + +// prefetcher is the part of AgentRunner the reconciler drives (a test seam). +type prefetcher interface { + Prefetch(ctx context.Context, cfg *agentConfig) error +} + +// overlayFetcher is the test seam over sdk.Client.AgentConfig.Get. +type overlayFetcher interface { + Get(ctx context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) +} + +// configReporter is the test seam over sdk.Client.AgentConfig.Report. +type configReporter interface { + Report(ctx context.Context, instanceID uuid.UUID, r agentconfig.Report) error +} + +// remoteAPI bundles the remote configuration calls. +type remoteAPI interface { + overlayFetcher + configReporter +} + +// sdkRemote adapts the SDK client to remoteAPI. +type sdkRemote struct { + client *sdk.Client +} + +func (s sdkRemote) Get(ctx context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) { + return s.client.AgentConfig.Get(ctx, ifNoneMatch) +} + +func (s sdkRemote) Report(ctx context.Context, instanceID uuid.UUID, r agentconfig.Report) error { + return s.client.AgentConfig.Report(ctx, instanceID, r) +} + +// newSDKRemote builds the remote configuration client from the (locked, file-only) api block. +func newSDKRemote(c agentconfig.Config) remoteAPI { + if c.API == nil { + return nil + } + cfg := &sdk.Config{BaseURL: strings.TrimSpace(c.API.URL)} + if c.API.HasAuth() { + cfg.AgentAuth = &sdk.AgentAuthConfig{ + ClientID: strings.TrimSpace(c.API.Auth.ClientID), + ClientSecret: strings.TrimSpace(c.API.Auth.ClientSecret), + } + } + return sdkRemote{client: sdk.NewClient(nil, cfg)} +} + +// runFunc runs one configuration until it is cancelled (daemon) or completes (one-shot). +type runFunc func(ctx context.Context, cfg *agentConfig) error + +type trigger int + +const ( + triggerFile trigger = iota + triggerPoll +) + +// reconciler is the single writer of the configuration state. File and remote triggers are +// serialized in one goroutine: a trigger builds a complete candidate and only then cancels the +// running configuration; a failure tears nothing down. +type reconciler struct { + cmd *cobra.Command + configPath string + store *agentstate.Store + runner prefetcher + logger hclog.Logger + instanceID uuid.UUID + fileEvents chan struct{} + runFailed chan *candidate + // onStartupFailure records a startup download failure of the file-only configuration + // (plugin run state + startup-failure agent evidence, as AgentRunner.Run always did). + onStartupFailure func(ctx context.Context, cfg *agentConfig, err error) + // debounce coalesces bursts of config file events (editors write in several steps). + debounce time.Duration + + // newRemote builds the remote client from a base (a test seam). + newRemote func(agentconfig.Config) remoteAPI + // lookupEnv resolves ${env:NAME} placeholders (a test seam). + lookupEnv func(string) (string, bool) + // pluginLib reads the agent library version of a prefetched plugin source (R76); + // nil leaves the plugins report empty. + pluginLib pluginLibFunc + now func() time.Time + + mu sync.Mutex // guards active, pending, cancelRun + active *candidate + pending *candidate + cancelRun context.CancelFunc + + // Everything below is owned by the reconciler goroutine (startup runs before loop). + base *baseSnapshot + remote remoteAPI + remoteKey string + cache *agentstate.Cache + lastOutcome *applyError + attempted *int64 + warnedMode bool + + fetchBackoffUntil time.Time + reportBackoffUntil time.Time + loggedOnce map[string]bool + + failedKey string // overlayKey of the target in failed backoff + failedBase string + failedRetryAt time.Time + failedInterval time.Duration + + report reportState +} + +func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Store, runner prefetcher, logger hclog.Logger) *reconciler { + if logger == nil { + logger = hclog.NewNullLogger() + } + rc := &reconciler{ + cmd: cmd, + configPath: configPath, + store: store, + runner: runner, + logger: logger.Named("reconciler"), + fileEvents: make(chan struct{}, 1), + runFailed: make(chan *candidate, 1), + debounce: 500 * time.Millisecond, + lookupEnv: os.LookupEnv, + now: time.Now, + loggedOnce: map[string]bool{}, + newRemote: newSDKRemote, + } + return rc +} + +func (rc *reconciler) rcfg() agentconfig.RemoteConfig { + return rc.base.declared.EffectiveRemoteConfig() +} + +func isApplyMode(mode string) bool { + return mode == agentconfig.ModeApplySafe || mode == agentconfig.ModeApplyAll +} + +// setBase installs a new base: warnings are logged, the remote client is rebuilt when the api +// block changed, and the cache is (re)bound to the base's identity. +func (rc *reconciler) setBase(base *baseSnapshot) { + old := rc.base + rc.base = base + rc.logWarnings(base.warnings) + if !rc.warnedMode && base.declared.RemoteConfig != nil { + mode := base.declared.RemoteConfig.Mode + if mode != "" && mode != agentconfig.ModeOff && !base.declared.API.HasAuth() { + rc.warnedMode = true + rc.logger.Warn("remote_config.mode needs api.auth credentials; remote configuration is off", "mode", mode) + } + } + + key := remoteKey(base.declared) + if rc.remote == nil || key != rc.remoteKey { + rc.remote = nil + if base.declared.API.HasAuth() { + rc.remote = rc.newRemote(base.declared) + } + rc.remoteKey = key + rc.fetchBackoffUntil, rc.reportBackoffUntil = time.Time{}, time.Time{} + } + + id := cacheIdentity(base.declared) + if rc.cache == nil || rc.cache.Identity != id { + cache, err := rc.store.LoadCache(id) + rc.cache = cache + if errors.Is(err, agentstate.ErrCacheCorrupt) { + rc.logger.Error("Remote config cache is corrupt; continuing without it", "path", rc.store.CachePath(), "error", err) + rc.lastOutcome = failed(agentconfig.ReasonCacheCorrupt, err) + } + } + if old != nil && old.fingerprint != base.fingerprint && rc.cache.Rejected != nil { + // A base change re-classifies a remembered rejection. + rc.cache.Rejected = nil + rc.saveCache() + } +} + +func remoteKey(c agentconfig.Config) string { + if c.API == nil { + return "" + } + raw, _ := json.Marshal(c.API) + return string(raw) +} + +func cacheIdentity(c agentconfig.Config) agentstate.Identity { + id := agentstate.Identity{} + if c.API != nil { + id.APIURL = strings.TrimSpace(c.API.URL) + if c.API.Auth != nil { + id.ClientID = strings.TrimSpace(c.API.Auth.ClientID) + } + } + return id +} + +func (rc *reconciler) saveCache() { + if rc.cache == nil { + return + } + if err := rc.store.SaveCache(rc.cache); err != nil && rc.logOnce("cache-save") { + rc.logger.Warn("Could not persist the remote config cache", "path", rc.store.CachePath(), "error", err) + } +} + +// logOnce reports whether key has not been logged yet, and marks it. +func (rc *reconciler) logOnce(key string) bool { + if rc.loggedOnce[key] { + return false + } + rc.loggedOnce[key] = true + return true +} + +// startup runs the startup ladder (R32): load the file, fetch (apply modes, bounded), then the +// first candidate that prepares wins among base+fetched, base+applied and base only. Only an +// unusable local configuration is an error (exit 1, as before). +func (rc *reconciler) startup(ctx context.Context) (*candidate, error) { + base, err := loadBase(rc.cmd, rc.configPath) + if err != nil { + return nil, fmt.Errorf("config file: %w", err) + } + rc.setBase(base) + rcfg := rc.rcfg() + if isApplyMode(rcfg.Mode) { + rc.fetch(ctx) + } + + var active *candidate + var outcome *applyError + for _, target := range rc.ladder(rcfg.Mode) { + cand, aerr := rc.prepare(ctx, base, target) + if target != nil && target == rc.cache.Fetched { + rev := target.Revision + rc.attempted = &rev + } + if aerr != nil { + if target == nil { + if aerr.runtime != nil && rc.onStartupFailure != nil { + rc.onStartupFailure(ctx, aerr.runtime, aerr.Err) + } + return nil, aerr + } + rc.logger.Warn("Could not apply the remote configuration at startup", "revision", target.Revision, "status", aerr.Status, "reason", aerr.Reason, "error", aerr.Err) + rc.recordFailure(target, aerr) + if outcome == nil { + outcome = aerr + } + continue + } + active = cand + if target != nil { + rc.cache.Applied = target + rc.saveCache() + } + break + } + if rej := rc.rememberedRejection(rcfg.Mode); rej != nil && outcome == nil { + // The ladder skipped the fetched overlay because it was rejected before the restart: + // report that rejection again, or the API sees an applied, never-attempted revision + // (pending) until a new revision is published (a 304 never re-prepares it). + outcome = rej + } + if outcome != nil { + rc.lastOutcome = outcome + } + rc.maybeReport(ctx, active, rc.lastOutcome) + return active, nil +} + +// ladder lists the startup targets in order; nil is the file only. +func (rc *reconciler) ladder(mode string) []*agentstate.OverlayRecord { + if !isApplyMode(mode) { + return []*agentstate.OverlayRecord{nil} + } + var out []*agentstate.OverlayRecord + if f := rc.cache.Fetched; f != nil && !rc.rememberedRejected(f) { + out = append(out, f) + } + if a := rc.cache.Applied; a != nil && (len(out) == 0 || !sameOverlay(a, out[0])) { + out = append(out, a) + } + return append(out, nil) +} + +func sameOverlay(a, b *agentstate.OverlayRecord) bool { + switch { + case a == nil || b == nil: + return a == b + case a.ETag != "" || b.ETag != "": + return a.ETag == b.ETag && a.Revision == b.Revision + default: + return a.Revision == b.Revision && bytes.Equal(a.Overlay, b.Overlay) + } +} + +// overlayKey identifies an overlay for the rejected memory and the failed backoff: the raw +// ETag, or revision + sha256(overlay) when a response carried no ETag (a stripping proxy), so +// one rejection never blocks every later revision. nil (the file only) has its own key. +func overlayKey(rec *agentstate.OverlayRecord) string { + switch { + case rec == nil: + return "file-only" + case rec.ETag != "": + return "etag:" + rec.ETag + default: + return fmt.Sprintf("rev:%d:%s", rec.Revision, overlayDigest(rec.Overlay)) + } +} + +func overlayDigest(raw []byte) string { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +func (rc *reconciler) rememberedRejected(rec *agentstate.OverlayRecord) bool { + r := rc.cache.Rejected + if r == nil || rec == nil || r.BaseFingerprint != rc.base.fingerprint { + return false + } + if r.ETag != "" || rec.ETag != "" { + return r.ETag == rec.ETag + } + return r.Revision == rec.Revision && r.OverlaySHA256 == overlayDigest(rec.Overlay) +} + +// rememberedRejection is the outcome to report while the fetched overlay is remembered as +// rejected for this base (apply modes only): the persisted rejection, with rc.attempted set to +// its revision. nil when the fetched overlay is not remembered-rejected. +func (rc *reconciler) rememberedRejection(mode string) *applyError { + if !isApplyMode(mode) || rc.cache == nil { + return nil + } + f, r := rc.cache.Fetched, rc.cache.Rejected + if f == nil || !rc.rememberedRejected(f) { + return nil + } + rev := f.Revision + rc.attempted = &rev + aerr := &applyError{ + Status: r.Status, + Reason: r.Reason, + Unsafe: slices.Clone(r.Unsafe), + } + if r.Error != "" { + aerr.Err = errors.New(r.Error) + } + return aerr +} + +// recordFailure remembers a rejected revision for (overlay key, base), or starts the failed +// backoff. A file-only candidate that fails to prepare is backed off too. +func (rc *reconciler) recordFailure(target *agentstate.OverlayRecord, aerr *applyError) { + if target == nil { + rc.startFailedBackoff(nil, rc.base.fingerprint) + return + } + if aerr.Status == agentconfig.StatusRejected { + msg := "" + if aerr.Err != nil { + msg = aerr.Err.Error() + } + rc.cache.Rejected = &agentstate.RejectedRecord{ + Revision: target.Revision, + ETag: target.ETag, + OverlaySHA256: overlayDigest(target.Overlay), + BaseFingerprint: rc.base.fingerprint, + Status: aerr.Status, + Reason: aerr.Reason, + Error: msg, + Unsafe: aerr.Unsafe, + } + rc.saveCache() + return + } + rc.startFailedBackoff(target, rc.base.fingerprint) +} + +// startFailedBackoff starts (or doubles, for the same target and base) the retry delay of a +// target (nil = the file only) that failed to prepare or to run on base baseFingerprint. +func (rc *reconciler) startFailedBackoff(target *agentstate.OverlayRecord, baseFingerprint string) { + key := overlayKey(target) + if rc.failedKey == key && rc.failedBase == baseFingerprint && rc.failedInterval > 0 { + rc.failedInterval = min(rc.failedInterval*2, failedRetryMax) + } else { + rc.failedInterval = failedRetryMin + } + rc.failedKey, rc.failedBase = key, baseFingerprint + rc.failedRetryAt = rc.now().Add(rc.failedInterval) +} + +func (rc *reconciler) inFailedBackoff(target *agentstate.OverlayRecord) bool { + return rc.failedInterval > 0 && rc.failedKey == overlayKey(target) && + rc.failedBase == rc.base.fingerprint && rc.now().Before(rc.failedRetryAt) +} + +// clearFailedBackoff forgets the failed backoff after target prepared on the current base, +// unless it is the target in backoff: that one may still fail to RUN, and the retry delay +// must keep growing instead of restarting at failedRetryMin (no flapping every poll). +func (rc *reconciler) clearFailedBackoff(target *agentstate.OverlayRecord) { + if rc.failedKey == overlayKey(target) && rc.failedBase == rc.base.fingerprint { + return + } + rc.failedKey, rc.failedBase, rc.failedInterval = "", "", 0 +} + +// prepare builds a candidate from a base and an optional overlay (G3.3). Cheap checks run +// first and nothing touches the network before the Classify gate passes. It never touches the +// running configuration. +func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agentstate.OverlayRecord) (*candidate, *applyError) { + rcfg := base.declared.EffectiveRemoteConfig() + if !isApplyMode(rcfg.Mode) { + ov = nil // report/off: the file only + } + + declared := base.declared + var touched []string + if ov != nil { + // Strict decode of the overlay: the only strict decode in the agent (R27, R51). + if err := agentconfig.ValidateOverlay(ov.Overlay); err != nil { + return nil, overlayValidationError(err) + } + changes, err := agentconfig.Classify(base.declared, ov.Overlay, rcfg) + if err != nil { + return nil, rejected(agentconfig.ReasonInvalidConfig, err) + } + if ok, why := agentconfig.WillApply(rcfg, changes); !ok { + aerr := rejected(why, fmt.Errorf("revision %d %s", ov.Revision, strings.ReplaceAll(why, "-", " "))) + for _, c := range changes { + if c.Safety != agentconfig.Safe { + aerr.Unsafe = append(aerr.Unsafe, c) + } + } + return nil, aerr + } + merged, err := agentconfig.Merge(base.declared, ov.Overlay) + if err != nil { + return nil, rejected(agentconfig.ReasonInvalidConfig, err) + } + touched, err = overlayTouched(base.declared, merged) + if err != nil { + return nil, failed(agentconfig.ReasonInternal, err) + } + declared = merged + } + + part := partitionByOrigin(declared.Validate(), touched) + if len(part.overlay) > 0 || len(part.fatal) > 0 { + errs := agentconfig.ValidationErrors(append(append([]agentconfig.FieldError{}, part.overlay...), part.fatal...)) + if ov == nil { + return nil, failed(agentconfig.ReasonInvalidConfig, fmt.Errorf("config file: %w", errs)) + } + return nil, rejected(agentconfig.ReasonInvalidConfig, errs) + } + + resolved, envWarnings, err := resolveEnv(declared, base.declared, rc.lookupEnv) + switch { + case errors.Is(err, agentconfig.ErrEnvForbidden): + return nil, rejected(agentconfig.ReasonForbiddenChanges, err) + case errors.Is(err, agentconfig.ErrEnvMissing): + return nil, failed(agentconfig.ReasonEnvMissing, err) + case err != nil: + return nil, failed(agentconfig.ReasonInternal, err) + } + for _, w := range envWarnings { + if rc.logOnce("env-missing\x00" + w.Path + "\x00" + w.Message) { + rc.logWarnings([]agentconfig.FieldError{w}) + } + } + + runtime, err := toRuntime(resolved, part.skip) + if err != nil { + return nil, failed(agentconfig.ReasonInvalidConfig, err) + } + prefetchCtx, cancelPrefetch := context.WithTimeout(ctx, prepareNetworkTimeout) + err = rc.runner.Prefetch(prefetchCtx, runtime) + cancelPrefetch() + if err != nil { + aerr := failed(agentconfig.ReasonDownloadFailed, err) + aerr.runtime = runtime + return nil, aerr + } + var plugins []agentconfig.PluginReport + if rcfg.Mode != agentconfig.ModeOff { + plugins = rc.pluginReports(ctx, runtime) + } + + // The digest is over the UNRESOLVED form with the same masking as the reported effective + // config (R55): it never changes when a secret rotates. + digest := agentconfig.Digest(declared, base.redactOpts()...) + meta := syncMeta{Digest: digest, Mode: rcfg.Mode} + if ov != nil { + meta.AppliedRevision = ov.Revision + } + runtime.setSync(meta) + return &candidate{ + base: base, + overlay: ov, + declared: declared, + runtime: runtime, + digest: digest, + identity: candidateIdentity(declared), + warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), + plugins: plugins, + }, nil +} + +// overlayTouched returns the pointers an overlay changed, computed on the unresolved forms so +// env resolution never counts as an overlay change (R34). +func overlayTouched(base, merged agentconfig.Config) ([]string, error) { + a, err := json.Marshal(base) + if err != nil { + return nil, err + } + b, err := json.Marshal(merged) + if err != nil { + return nil, err + } + diff, err := agentconfig.DiffJSON(a, b) + if err != nil { + return nil, err + } + out := make([]string, 0, len(diff)) + for _, d := range diff { + out = append(out, d.Path) + } + return out, nil +} + +// overlayValidationError maps ValidateOverlay's FieldError codes to a report reason (R43). +// With several errors the first in precedence order wins: forbidden, unknown-field, +// invalid-type, invalid-config. All errors are kept in the message. +func overlayValidationError(err error) *applyError { + var errs agentconfig.ValidationErrors + if !errors.As(err, &errs) { + return rejected(agentconfig.ReasonInvalidConfig, err) + } + rank := map[string]int{ + agentconfig.ReasonForbiddenChanges: 0, + agentconfig.ReasonUnknownField: 1, + agentconfig.ReasonInvalidType: 2, + agentconfig.ReasonInvalidConfig: 3, + } + reason := agentconfig.ReasonInvalidConfig + for _, e := range errs { + r := agentconfig.ReasonInvalidConfig + switch e.Code { + case agentconfig.FieldCodeLockedKey, agentconfig.FieldCodeForbiddenEnv: + r = agentconfig.ReasonForbiddenChanges + case agentconfig.FieldCodeUnknownField: + r = agentconfig.ReasonUnknownField + case agentconfig.FieldCodeInvalidType: + r = agentconfig.ReasonInvalidType + } + if rank[r] < rank[reason] { + reason = r + } + } + return rejected(reason, errs) +} + +// candidateIdentity hashes the declared config, including the values the digest masks or +// omits, so any change that affects the runtime is a different configuration. +func candidateIdentity(c agentconfig.Config) string { + raw, err := agentconfig.CanonicalJSON(struct { + Config agentconfig.Config `json:"config"` + }{c}) + if err != nil { + raw = []byte(err.Error()) + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) +} + +// bind records the running candidate and how to cancel it. If a swap raced in between two +// runs, the new run is cancelled at once so the pending candidate is picked up. +func (rc *reconciler) bind(active *candidate, cancel context.CancelFunc) { + rc.mu.Lock() + defer rc.mu.Unlock() + rc.active = active + rc.cancelRun = cancel + if rc.pending != nil { + cancel() + } +} + +// adopt records cand, whose identity equals old's, as the running (or pending) configuration +// WITHOUT a restart: the runtime old runs is kept and only its sync metadata changes, so the +// heartbeat, evidence and report show cand's applied revision, and sameAsActive holds for +// cand's base and overlay on the next trigger (no re-prepare every poll). +func (rc *reconciler) adopt(old, cand *candidate) *candidate { + adopted := *cand + if old.runtime != nil { + adopted.runtime = old.runtime + old.runtime.setSync(cand.runtime.syncInfo()) + } + rc.mu.Lock() + defer rc.mu.Unlock() + switch { + case rc.pending == old: + rc.pending = &adopted + case rc.active == old: + rc.active = &adopted + } + return &adopted +} + +// record returns the reconciler's current record of the candidate running c.runtime: adopt +// may have replaced it in place. +func (rc *reconciler) record(c *candidate) *candidate { + rc.mu.Lock() + defer rc.mu.Unlock() + if rc.active != nil && c != nil && rc.active.runtime == c.runtime { + return rc.active + } + return c +} + +// swap makes next the pending candidate and cancels the running one. +func (rc *reconciler) swap(next *candidate) { + rc.mu.Lock() + defer rc.mu.Unlock() + rc.pending = next + if rc.cancelRun != nil { + rc.cancelRun() + } +} + +func (rc *reconciler) takePending() *candidate { + rc.mu.Lock() + defer rc.mu.Unlock() + next := rc.pending + rc.pending = nil + return next +} + +// current returns the candidate that is running, or about to run when a swap is pending. +func (rc *reconciler) current() *candidate { + rc.mu.Lock() + defer rc.mu.Unlock() + if rc.pending != nil { + return rc.pending + } + return rc.active +} + +// run drives run with the active candidate. A cancelled run (swap) picks up the pending +// candidate; a run that fails on its own falls back to the previous candidate once. +func (rc *reconciler) run(active *candidate, run runFunc) error { + var previous, failedRun *candidate + for { + runCtx, cancel := context.WithCancel(context.Background()) + rc.bind(active, cancel) + if failedRun != nil { + // Notify only once the fallback is bound, so the reconciler's current() is the + // fallback, never the candidate that failed. + rc.notifyRunFailed(failedRun) + failedRun = nil + } + runErr := run(runCtx, active.runtime) + reload := runCtx.Err() != nil + cancel() + active = rc.record(active) + if runErr != nil && !reload { + if previous != nil { + rc.logger.Error("Configuration failed to run; falling back to the previous configuration", "error", runErr) + failedRun = active + active, previous = previous, nil + continue + } + return runErr + } + if !active.runtime.Daemon { + return runErr + } + next := rc.takePending() + if next == nil { + continue + } + previous, active = active, next + } +} + +// notifyRunFailed hands a failed-to-run candidate to the reconciler goroutine. +func (rc *reconciler) notifyRunFailed(c *candidate) { + select { + case rc.runFailed <- c: + default: + } +} + +// pollDelay is the next poll delay: poll_interval ±10% jitter, at least MinPollInterval. +func (rc *reconciler) pollDelay() time.Duration { + interval, err := time.ParseDuration(rc.rcfg().PollInterval) + if err != nil { + interval = agentconfig.DefaultPollInterval + } + interval = max(interval, agentconfig.MinPollInterval) + jitter := time.Duration((rand.Float64()*0.2 - 0.1) * float64(interval)) + return max(interval+jitter, agentconfig.MinPollInterval) +} + +// loop is the daemon's reconcile goroutine: config file events (debounced) and the poll +// ticker. The poller lives here, not in the heartbeat cron, because it triggers reloads. It +// returns when ctx is done. +func (rc *reconciler) loop(ctx context.Context) { + var debounce <-chan time.Time + poll := time.NewTimer(rc.pollDelay()) + defer poll.Stop() + for { + select { + case <-ctx.Done(): + return + case <-rc.fileEvents: + if debounce == nil { + debounce = time.After(rc.debounce) + } + case <-debounce: + debounce = nil + rc.reconcile(ctx, triggerFile) + case c := <-rc.runFailed: + rc.onRunFailed(ctx, c) + case <-poll.C: + rc.reconcile(ctx, triggerPoll) + poll.Reset(rc.pollDelay()) + } + } +} + +// onRunFailed records that a prepared candidate failed to run (the run loop already fell back). +func (rc *reconciler) onRunFailed(ctx context.Context, c *candidate) { + aerr := failed(agentconfig.ReasonInternal, errors.New("the configuration failed to start; running the previous configuration")) + if c != nil { + // File-only candidates are backed off too: otherwise every poll re-prepares the new + // base, cancels the healthy configuration, fails and falls back again. + baseFingerprint := rc.base.fingerprint + if c.base != nil { + baseFingerprint = c.base.fingerprint + } + rc.startFailedBackoff(c.overlay, baseFingerprint) + } + if c != nil && c.overlay != nil { + if sameOverlay(rc.cache.Applied, c.overlay) { + rc.cache.Applied = nil + if prev := rc.current(); prev != nil && prev.overlay != nil { + rc.cache.Applied = prev.overlay + } + rc.saveCache() + } + } + rc.lastOutcome = aerr + rc.maybeReport(ctx, rc.current(), aerr) +} + +// reconcile handles one trigger (G3.4). All work runs in the reconciler goroutine, so two +// prepares never overlap. +func (rc *reconciler) reconcile(ctx context.Context, t trigger) { + if t == triggerFile { + base, err := loadBase(rc.cmd, rc.configPath) + if err != nil { + rc.logger.Error("Config file is invalid; keeping the running configuration", "error", err) + aerr := failed(agentconfig.ReasonInvalidConfig, fmt.Errorf("config file: %w", err)) + rc.lastOutcome = aerr + rc.maybeReport(ctx, rc.current(), aerr) + return + } + rc.setBase(base) + } + rcfg := rc.rcfg() + if isApplyMode(rcfg.Mode) && t == triggerPoll { + rc.fetch(ctx) + } + + target := rc.targetOverlay(rcfg.Mode) + if isApplyMode(rcfg.Mode) && target != nil && target == rc.cache.Fetched { + rev := target.Revision + rc.attempted = &rev + } + // While the fetched overlay stays rejected (a 304 never re-prepares it), it remains the + // attempted revision and its rejection the outcome, even when the fallback re-prepares. + remembered := rc.rememberedRejection(rcfg.Mode) + active := rc.current() + if rc.sameAsActive(active, target) { + rc.maybeReport(ctx, active, rc.lastOutcome) + return + } + if target != nil && rc.rememberedRejected(target) { + // The only overlay left (the applied one) was rejected for this base, e.g. after a + // conflicting file edit: keep the last-known-good configuration instead of + // re-preparing it on every poll (§5.4, G3.4). + rc.maybeReport(ctx, active, rc.lastOutcome) + return + } + if rc.inFailedBackoff(target) { + rc.maybeReport(ctx, active, rc.lastOutcome) + return + } + + cand, aerr := rc.prepare(ctx, rc.base, target) + if aerr != nil { + rc.logger.Warn("Could not apply the configuration; keeping the running configuration", "status", aerr.Status, "reason", aerr.Reason, "error", aerr.Err) + rc.recordFailure(target, aerr) + rc.lastOutcome = aerr + rc.maybeReport(ctx, active, aerr) + return + } + rc.clearFailedBackoff(target) + if isApplyMode(rcfg.Mode) { + rc.cache.Applied = target + rc.saveCache() + } + rc.lastOutcome = remembered + if active != nil && active.identity == cand.identity { + rc.logger.Debug("Trigger did not change the effective configuration; recording it without a restart", "revision", revisionForLog(target)) + rc.maybeReport(ctx, rc.adopt(active, cand), rc.lastOutcome) + return + } + rc.logger.Info("Applying the new configuration", "revision", revisionForLog(target)) + rc.swap(cand) + rc.maybeReport(ctx, cand, rc.lastOutcome) +} + +func revisionForLog(ov *agentstate.OverlayRecord) any { + if ov == nil { + return "file-only" + } + return ov.Revision +} + +// targetOverlay is the overlay the agent should run: none in report/off; otherwise the newest +// fetched one unless it was rejected for this base, else the applied one. +func (rc *reconciler) targetOverlay(mode string) *agentstate.OverlayRecord { + if !isApplyMode(mode) || rc.cache == nil { + return nil + } + if f := rc.cache.Fetched; f != nil && !rc.rememberedRejected(f) { + return f + } + return rc.cache.Applied +} + +// sameAsActive reports whether the active candidate already runs this base and target. +func (rc *reconciler) sameAsActive(active *candidate, target *agentstate.OverlayRecord) bool { + return active != nil && active.base != nil && + bytes.Equal(active.base.raw, rc.base.raw) && active.base.fingerprint == rc.base.fingerprint && + sameOverlay(active.overlay, target) +} + +// fetch polls the API for the overlay (R8), honoring the error backoffs. The cached overlay +// keeps applying on any error. +func (rc *reconciler) fetch(ctx context.Context) { + if rc.remote == nil || rc.now().Before(rc.fetchBackoffUntil) { + return + } + fetchCtx, cancel := context.WithTimeout(ctx, remoteRequestTimeout) + defer cancel() + res, err := rc.remote.Get(fetchCtx, rc.cache.IfNoneMatch()) + if err != nil { + rc.handleRemoteError("fetch", err, &rc.fetchBackoffUntil) + return + } + switch { + case res.NotModified: + case res.Document != nil: + rc.cache.Fetched = &agentstate.OverlayRecord{ + Revision: res.Document.Revision, + ETag: res.ETag, + Overlay: append(json.RawMessage(nil), res.Document.Overlay...), + FetchedAt: rc.now().UTC(), + } + rc.saveCache() + } +} + +// handleRemoteError applies the R8/R36 error table to a config route error. +func (rc *reconciler) handleRemoteError(op string, err error, backoff *time.Time) { + var statusErr *sdk.APIStatusError + switch { + case errors.Is(err, sdk.ErrRemoteConfigUnsupported): + if rc.logOnce(op + ":unsupported") { + rc.logger.Info("The API does not support remote agent configuration; running on the cached overlay or the file", "op", op, "retry_in", remoteAuthBackoff) + } + *backoff = rc.now().Add(remoteAuthBackoff) + case errors.Is(err, sdk.ErrAgentAuthRequired): + rc.logger.Error("Remote configuration requires api.auth credentials", "op", op) + *backoff = rc.now().Add(remoteAuthBackoff) + case errors.As(err, &statusErr) && (statusErr.StatusCode == 401 || statusErr.StatusCode == 403): + if rc.logOnce(fmt.Sprintf("%s:%d", op, statusErr.StatusCode)) { + msg := "The API rejected the agent's credentials for remote configuration" + if statusErr.StatusCode == 403 { + msg = "The agent's service account lacks the agent:sync permission for remote configuration" + } + rc.logger.Error(msg, "op", op, "status", statusErr.StatusCode, "retry_in", remoteAuthBackoff) + } + *backoff = rc.now().Add(remoteAuthBackoff) + default: + rc.logger.Warn("Remote configuration request failed; retrying on the next poll", "op", op, "error", err) + } +} + +func (rc *reconciler) logWarnings(warnings []agentconfig.FieldError) { + for _, w := range warnings { + if isToleratedFileRule(w) { + rc.logger.Warn("Ignoring a problem in the config file; the plugin is skipped", "path", w.Path, "error", w.Message) + continue + } + rc.logger.Warn("Ignoring a problem in the config file; the value is kept unchanged", "path", w.Path, "error", w.Message) + } +} + +// signalFile queues a file event without blocking. +func (rc *reconciler) signalFile() { + select { + case rc.fileEvents <- struct{}{}: + default: + } +} + +// watchFile watches the config file on a dedicated viper instance whose OnConfigChange only +// signals; loading always happens in the reconciler goroutine on a fresh viper. Known limit +// (R32 follow-up): viper stops watching after a Remove event. +func (rc *reconciler) watchFile() (stop func()) { + var stopped atomic.Bool + w := viper.New() + w.SetConfigFile(rc.configPath) + w.OnConfigChange(func(in fsnotify.Event) { + if stopped.Load() { + return + } + rc.logger.Debug("config file changed", "path", in.Name) + rc.signalFile() + }) + w.WatchConfig() + return func() { stopped.Store(true) } +} diff --git a/cmd/reconciler_test.go b/cmd/reconciler_test.go new file mode 100644 index 0000000..d89ddc0 --- /dev/null +++ b/cmd/reconciler_test.go @@ -0,0 +1,529 @@ +package cmd + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + "time" + + "github.com/compliance-framework/agent/internal/agentstate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/uuid" +) + +// fakePrefetcher records Prefetch calls and can be told to fail or to hang. +type fakePrefetcher struct { + mu sync.Mutex + calls int + err error + block bool +} + +func (f *fakePrefetcher) Prefetch(ctx context.Context, _ *agentConfig) error { + f.mu.Lock() + f.calls++ + err, block := f.err, f.block + f.mu.Unlock() + if block { + <-ctx.Done() + return ctx.Err() + } + return err +} + +func (f *fakePrefetcher) setBlock(block bool) { + f.mu.Lock() + f.block = block + f.mu.Unlock() +} + +// errStopRun ends a test's run func (and so rc.run) on a test-owned channel. +var errStopRun = errors.New("test stopped the run") + +func (f *fakePrefetcher) setErr(err error) { + f.mu.Lock() + f.err = err + f.mu.Unlock() +} + +func (f *fakePrefetcher) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +const reconcilerTestConfig = ` +daemon: true +api: + url: http://localhost:8080 +plugins: + ssh: + source: ./plugin-ssh + schedule: "%s" +` + +func newTestReconciler(t *testing.T, content string) (*reconciler, *fakePrefetcher, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + pf := &fakePrefetcher{} + rc := newReconciler(AgentCmd(), path, agentstate.Open(filepath.Join(dir, "state"), nil), pf, nil) + return rc, pf, path +} + +func configWithSchedule(schedule string) string { + return strings.Replace(reconcilerTestConfig, "%s", schedule, 1) +} + +// runningReconciler starts rc.run with a fake run func that blocks until cancelled and +// records every config it was given. +type runRecorder struct { + mu sync.Mutex + configs []*agentConfig + started chan *agentConfig + fail func(cfg *agentConfig) error +} + +func newRunRecorder() *runRecorder { + return &runRecorder{started: make(chan *agentConfig, 100)} +} + +func (r *runRecorder) run(ctx context.Context, cfg *agentConfig) error { + r.mu.Lock() + r.configs = append(r.configs, cfg) + fail := r.fail + r.mu.Unlock() + r.started <- cfg + if fail != nil { + if err := fail(cfg); err != nil { + return err + } + } + <-ctx.Done() + return nil +} + +func (r *runRecorder) runCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.configs) +} + +func waitStarted(t *testing.T, r *runRecorder) *agentConfig { + t.Helper() + select { + case cfg := <-r.started: + return cfg + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for a run to start") + return nil + } +} + +func expectNoStart(t *testing.T, r *runRecorder, within time.Duration) { + t.Helper() + select { + case cfg := <-r.started: + t.Fatalf("unexpected run started with %#v", cfg) + case <-time.After(within): + } +} + +func startReconciler(t *testing.T, rc *reconciler, rec *runRecorder) { + t.Helper() + active, err := rc.startup(context.Background()) + if err != nil { + t.Fatalf("startup: %v", err) + } + go func() { _ = rc.run(active, rec.run) }() + waitStarted(t, rec) +} + +func TestReconciler_InvalidFileAtStartupReturnsError(t *testing.T) { + rc, _, _ := newTestReconciler(t, "daemon: true\nplugins:\n ssh:\n source: ./x\n") + if _, err := rc.startup(context.Background()); err == nil || !strings.Contains(err.Error(), "/api") { + t.Fatalf("expected a config file error, got %v", err) + } +} + +func TestReconciler_InvalidEditKeepsRunning(t *testing.T) { + rc, pf, path := newTestReconciler(t, configWithSchedule("* * * * *")) + rec := newRunRecorder() + startReconciler(t, rc, rec) + + if err := os.WriteFile(path, []byte("daemon: true\nplugins: [\n"), 0o600); err != nil { + t.Fatal(err) + } + rc.reconcile(context.Background(), triggerFile) + expectNoStart(t, rec, 200*time.Millisecond) + if pf.callCount() != 1 { + t.Fatalf("an invalid file must not be prefetched, got %d calls", pf.callCount()) + } +} + +func TestReconciler_ValidEditCancelsOnce(t *testing.T) { + rc, _, path := newTestReconciler(t, configWithSchedule("* * * * *")) + rec := newRunRecorder() + startReconciler(t, rc, rec) + + if err := os.WriteFile(path, []byte(configWithSchedule("*/5 * * * *")), 0o600); err != nil { + t.Fatal(err) + } + rc.reconcile(context.Background(), triggerFile) + cfg := waitStarted(t, rec) + if got := *cfg.Plugins["ssh"].Schedule; got != "*/5 * * * *" { + t.Fatalf("new run has schedule %q", got) + } + // The same content again is not a change. + rc.reconcile(context.Background(), triggerFile) + expectNoStart(t, rec, 200*time.Millisecond) + if rec.runCount() != 2 { + t.Fatalf("expected exactly one reload, got %d runs", rec.runCount()) + } +} + +func TestReconciler_PrefetchFailureDoesNotCancel(t *testing.T) { + rc, pf, path := newTestReconciler(t, configWithSchedule("* * * * *")) + rec := newRunRecorder() + startReconciler(t, rc, rec) + + pf.setErr(errors.New("registry down")) + if err := os.WriteFile(path, []byte(configWithSchedule("*/5 * * * *")), 0o600); err != nil { + t.Fatal(err) + } + rc.reconcile(context.Background(), triggerFile) + expectNoStart(t, rec, 200*time.Millisecond) +} + +func TestReconciler_RunFailureFallsBackToPrevious(t *testing.T) { + rc, _, path := newTestReconciler(t, configWithSchedule("* * * * *")) + rec := newRunRecorder() + rec.fail = func(cfg *agentConfig) error { + if *cfg.Plugins["ssh"].Schedule == "*/5 * * * *" { + return errors.New("cache wiped after prepare") + } + return nil + } + startReconciler(t, rc, rec) + + if err := os.WriteFile(path, []byte(configWithSchedule("*/5 * * * *")), 0o600); err != nil { + t.Fatal(err) + } + rc.reconcile(context.Background(), triggerFile) + if got := *waitStarted(t, rec).Plugins["ssh"].Schedule; got != "*/5 * * * *" { + t.Fatalf("expected the new config to be tried, got %q", got) + } + if got := *waitStarted(t, rec).Plugins["ssh"].Schedule; got != "* * * * *" { + t.Fatalf("expected a fallback to the previous config, got %q", got) + } +} + +func TestReconciler_RapidFileEventsRace(t *testing.T) { + rc, _, path := newTestReconciler(t, configWithSchedule("* * * * *")) + rec := newRunRecorder() + rc.debounce = 5 * time.Millisecond + startReconciler(t, rc, rec) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go rc.loop(ctx) + + const last = "*/9 * * * *" // unique: no intermediate reload can satisfy the check + for i := 0; i < 50; i++ { + schedule := "*/5 * * * *" + switch { + case i == 49: + schedule = last + case i%2 == 0: + schedule = "*/7 * * * *" + } + if err := os.WriteFile(path, []byte(configWithSchedule(schedule)), 0o600); err != nil { + t.Fatal(err) + } + rc.signalFile() + time.Sleep(time.Millisecond) + } + // The last write wins: it is started, and it is still what runs once reloads quiesce. + deadline := time.After(5 * time.Second) + for applied := false; !applied; { + select { + case cfg := <-rec.started: + applied = *cfg.Plugins["ssh"].Schedule == last + case <-deadline: + t.Fatal("the last file write was never applied") + } + } + expectNoStart(t, rec, 300*time.Millisecond) + if got := *rc.current().runtime.Plugins["ssh"].Schedule; got != last { + t.Fatalf("the final running config has schedule %q, want %q", got, last) + } +} + +// TestReconciler_FileOnlyRunFailureBacksOff: a file edit that prepares but fails to run is +// not re-applied on every poll (it would cancel the healthy config and fall back each time). +func TestReconciler_FileOnlyRunFailureBacksOff(t *testing.T) { + rc, pf, path := newTestReconciler(t, configWithSchedule("* * * * *")) + rec := newRunRecorder() + rec.fail = func(cfg *agentConfig) error { + if *cfg.Plugins["ssh"].Schedule == "*/5 * * * *" { + return errors.New("cron setup failed") + } + return nil + } + startReconciler(t, rc, rec) + + if err := os.WriteFile(path, []byte(configWithSchedule("*/5 * * * *")), 0o600); err != nil { + t.Fatal(err) + } + rc.reconcile(context.Background(), triggerFile) + waitStarted(t, rec) // the new config, which fails + waitStarted(t, rec) // the fallback + select { + case c := <-rc.runFailed: + rc.onRunFailed(context.Background(), c) + case <-time.After(5 * time.Second): + t.Fatal("the run failure was never notified") + } + calls := pf.callCount() + for i := 0; i < 3; i++ { + rc.reconcile(context.Background(), triggerPoll) + } + expectNoStart(t, rec, 200*time.Millisecond) + if pf.callCount() != calls { + t.Fatalf("a file-only run failure must be backed off, got %d prepares", pf.callCount()-calls) + } +} + +// TestReconciler_StartupDownloadFailureReported: a download failure of the file-only config +// at startup is handed to onStartupFailure (startup-failure evidence, as Run did on main). +func TestReconciler_StartupDownloadFailureReported(t *testing.T) { + rc, pf, _ := newTestReconciler(t, configWithSchedule("* * * * *")) + pf.setErr(&downloadError{source: "./plugin-ssh", err: errors.New("registry down")}) + var gotCfg *agentConfig + var gotErr error + rc.onStartupFailure = func(_ context.Context, cfg *agentConfig, err error) { gotCfg, gotErr = cfg, err } + if _, err := rc.startup(context.Background()); err == nil { + t.Fatal("startup must fail") + } + if gotCfg == nil || gotCfg.Plugins["ssh"] == nil || !strings.Contains(gotErr.Error(), "registry down") { + t.Fatalf("onStartupFailure was not called with the runtime and error: %v %v", gotCfg, gotErr) + } +} + +func TestReportStartupFailureMarksPluginsAndSendsEvidence(t *testing.T) { + var mu sync.Mutex + var bodies []string + ar := NewAgentRunner() + ar.httpClient = newTestHTTPClient(func(r *http.Request) (*http.Response, error) { + raw, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, string(raw)) + mu.Unlock() + return jsonResponse(http.StatusCreated, ""), nil + }) + cfg := newTestAgentConfig("http://example.test", nil) + ar.ReportStartupFailure(context.Background(), cfg, &downloadError{source: "ghcr.io/some-plugin:v1", err: errors.New("registry down")}) + + if snap := ar.pluginRunSnapshot(); !slices.Contains(snap.Failed, "test-plugin") || !strings.Contains(snap.Errors["test-plugin"], "registry down") { + t.Fatalf("the plugin using the failed source must be marked failed, got %+v", snap) + } + mu.Lock() + defer mu.Unlock() + if len(bodies) != 1 || !strings.Contains(bodies[0], "Plugins with errors: test-plugin") { + t.Fatalf("expected one startup-failure evidence naming the failed plugin, got %d: %v", len(bodies), bodies) + } +} + +func TestReconciler_BindCancelsWhenSwapRacedIn(t *testing.T) { + rc, _, _ := newTestReconciler(t, configWithSchedule("* * * * *")) + rc.swap(&candidate{}) + ctx, cancel := context.WithCancel(context.Background()) + rc.bind(&candidate{}, cancel) + if ctx.Err() == nil { + t.Fatal("bind must cancel a run when a candidate is already pending") + } +} + +func TestPluginEnvironDropsAPICredentials(t *testing.T) { + got := pluginEnviron([]string{ + "CCF_API_AUTH_CLIENT_SECRET=s", + "ccf_api_auth_client_id=i", + "AWS_REGION=eu-west-1", + "CCF_INSTANCE_ID=abc", + "PATH=/bin", + }) + want := []string{"AWS_REGION=eu-west-1", "CCF_INSTANCE_ID=abc", "PATH=/bin"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("pluginEnviron = %v, want %v", got, want) + } +} + +func TestResolvePluginProtocols_CacheSurvivesLookupFailure(t *testing.T) { + ar := NewAgentRunner() + calls := 0 + ar.fetchAnnotations = func(context.Context, string, ...remote.Option) (map[string]string, error) { + calls++ + if calls == 1 { + return map[string]string{AnnotationProtocolVersionKey: "2"}, nil + } + return nil, errors.New("registry down") + } + newCfg := func() *agentConfig { + return &agentConfig{Plugins: map[string]*agentPlugin{ + "p": {Source: "ghcr.io/example/plugin:v1", ProtocolVersion: DefaultProtocolVersion}, + }} + } + first := newCfg() + ar.UpdateConfig(first) + ar.resolvePluginProtocols(context.Background()) + second := newCfg() + ar.UpdateConfig(second) + ar.resolvePluginProtocols(context.Background()) + if got := second.Plugins["p"].ProtocolVersion; got != RunnerV2ProtocolVersion { + t.Fatalf("expected the cached protocol 2 after a failing lookup, got %d", got) + } + if calls != 1 { + t.Fatalf("expected one annotation lookup, got %d", calls) + } +} + +func TestHeartbeatUsesStableInstanceIDAcrossRuns(t *testing.T) { + id := uuid.New() + ar := NewAgentRunner(WithInstanceID(id)) + ar.UpdateConfig(newTestAgentConfig("http://example.test", nil)) + var seen []uuid.UUID + var mu sync.Mutex + ar.sendHeartbeatFunc = func(_ context.Context, got uuid.UUID) error { + mu.Lock() + seen = append(seen, got) + mu.Unlock() + return nil + } + for i := 0; i < 2; i++ { + c, err := ar.setupHeartbeatCron(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, e := range c.Entries() { + e.Job.Run() + } + } + if len(seen) != 2 || seen[0] != id || seen[1] != id { + t.Fatalf("heartbeats used %v, want %s twice", seen, id) + } +} + +func TestRunDaemonDrainsInFlightRunsOnReload(t *testing.T) { + oldDrain := reloadDrainTimeout + reloadDrainTimeout = 10 * time.Second + t.Cleanup(func() { reloadDrainTimeout = oldDrain }) + + schedule := "@every 1s" + disabled := false + ar := NewAgentRunner() + ar.UpdateConfig(&agentConfig{ + Daemon: true, + ApiConfig: &apiConfig{Url: "http://127.0.0.1:1"}, + AgentEvidence: &agentEvidenceConfig{Enabled: &disabled}, + Plugins: map[string]*agentPlugin{"slow": {Source: "/tmp/slow", Schedule: &schedule}}, + }) + started := make(chan struct{}, 1) + var finishedCleanly atomic.Bool + ar.runPluginFunc = func(ctx context.Context, _ string, _ *agentPlugin) error { + select { + case started <- struct{}{}: + default: + } + time.Sleep(300 * time.Millisecond) + finishedCleanly.Store(ctx.Err() == nil) + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- ar.runDaemon(ctx) }() + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("plugin never started") + } + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("runDaemon: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("runDaemon did not return after the drain") + } + if !finishedCleanly.Load() { + t.Fatal("the in-flight run was cut short by the reload") + } +} + +// TestRunDaemonSignalDuringReloadDrainExits: a SIGTERM that arrives while a reload drains is +// not lost; it exits (R33: SIGTERM keeps its 30s) instead of waiting out the 5m drain. +func TestRunDaemonSignalDuringReloadDrainExits(t *testing.T) { + oldStop := daemonCronStopTimeout + daemonCronStopTimeout = 100 * time.Millisecond + t.Cleanup(func() { daemonCronStopTimeout = oldStop }) + + schedule := "@every 1s" + disabled := false + ar := NewAgentRunner() + ar.UpdateConfig(&agentConfig{ + Daemon: true, + ApiConfig: &apiConfig{Url: "http://127.0.0.1:1"}, + AgentEvidence: &agentEvidenceConfig{Enabled: &disabled}, + Plugins: map[string]*agentPlugin{"slow": {Source: "/tmp/slow", Schedule: &schedule}}, + }) + sigCh := make(chan chan<- os.Signal, 1) + ar.notifySignals = func(c chan<- os.Signal) { sigCh <- c } + exited := make(chan int, 1) + ar.exitFunc = func(code int) { exited <- code } + started := make(chan struct{}, 1) + release := make(chan struct{}) + ar.runPluginFunc = func(context.Context, string, *agentPlugin) error { + select { + case started <- struct{}{}: + default: + } + <-release // outlives the test's patience: only the signal can end the drain + return nil + } + defer close(release) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- ar.runDaemon(ctx) }() + sigs := <-sigCh + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("plugin never started") + } + cancel() // reload: the drain waits for the blocked run + time.Sleep(50 * time.Millisecond) + sigs <- syscall.SIGTERM + + select { + case code := <-exited: + if code != 0 { + t.Fatalf("exit code %d, want 0", code) + } + case <-time.After(5 * time.Second): + t.Fatal("a SIGTERM during the reload drain was lost") + } + <-done +} diff --git a/cmd/remote_test.go b/cmd/remote_test.go new file mode 100644 index 0000000..b54eb34 --- /dev/null +++ b/cmd/remote_test.go @@ -0,0 +1,1185 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/compliance-framework/agent/internal/agentstate" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" + "github.com/google/uuid" + "github.com/hashicorp/go-hclog" +) + +// fakeRemote is a scripted API for the remote configuration routes. +type fakeRemote struct { + mu sync.Mutex + overlay json.RawMessage // current overlay; nil = no document (404 if unsupported) + revision int64 + etag string + getErr error + reportErr func(n int, r agentconfig.Report) error + gets []string + reports []agentconfig.Report +} + +func (f *fakeRemote) Get(_ context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.gets = append(f.gets, ifNoneMatch) + if f.getErr != nil { + return nil, f.getErr + } + if f.overlay == nil { + return nil, sdk.ErrRemoteConfigUnsupported + } + if ifNoneMatch != "" && ifNoneMatch == f.etag { + return &sdk.AgentConfigResult{NotModified: true, ETag: f.etag}, nil + } + return &sdk.AgentConfigResult{ + Document: &agentconfig.OverlayDocument{Revision: f.revision, Overlay: f.overlay}, + ETag: f.etag, + }, nil +} + +func (f *fakeRemote) Report(_ context.Context, _ uuid.UUID, r agentconfig.Report) error { + f.mu.Lock() + defer f.mu.Unlock() + f.reports = append(f.reports, r) + if f.reportErr != nil { + return f.reportErr(len(f.reports), r) + } + return nil +} + +// publish sets a new overlay revision with an opaque ETag. +func (f *fakeRemote) publish(rev int64, overlay string) { + f.mu.Lock() + defer f.mu.Unlock() + f.revision = rev + f.overlay = json.RawMessage(overlay) + f.etag = fmt.Sprintf(`"r%d-%s"`, rev, uuid.New()) +} + +func (f *fakeRemote) lastReport(t *testing.T) agentconfig.Report { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.reports) == 0 { + t.Fatal("no report was sent") + } + return f.reports[len(f.reports)-1] +} + +func (f *fakeRemote) reportCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.reports) +} + +func (f *fakeRemote) getCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.gets) +} + +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + c.mu.Unlock() +} + +const remoteBaseConfig = ` +daemon: true +api: + url: http://api.test + auth: + client_id: 123e4567-e89b-12d3-a456-426614174000 + client_secret: s3cret +remote_config: + mode: %MODE% + trusted_sources: ["ghcr.io/trusted/*"] + overridable_config_flags: [%FLAGS%] +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-ssh:v1 + schedule: "* * * * *" + config: + host: localhost + token: t0ken +` + +type remoteHarness struct { + rc *reconciler + remote *fakeRemote + pf *fakePrefetcher + clock *fakeClock + path string + dir string +} + +func remoteConfig(mode, flags string) string { + return strings.NewReplacer("%MODE%", mode, "%FLAGS%", flags).Replace(remoteBaseConfig) +} + +func newRemoteHarness(t *testing.T, content string) *remoteHarness { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + h := &remoteHarness{ + remote: &fakeRemote{}, + pf: &fakePrefetcher{}, + clock: &fakeClock{now: time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)}, + path: path, + dir: dir, + } + h.rc = h.newReconciler() + return h +} + +// newReconciler builds a reconciler on the harness's files (a "restart"). +func (h *remoteHarness) newReconciler() *reconciler { + rc := newReconciler(AgentCmd(), h.path, agentstate.Open(filepath.Join(h.dir, "state"), nil), h.pf, nil) + rc.newRemote = func(agentconfig.Config) remoteAPI { return h.remote } + rc.now = h.clock.Now + rc.lookupEnv = func(string) (string, bool) { return "", false } + return rc +} + +func (h *remoteHarness) writeConfig(t *testing.T, content string) { + t.Helper() + if err := os.WriteFile(h.path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +func mustStartup(t *testing.T, rc *reconciler) *candidate { + t.Helper() + active, err := rc.startup(context.Background()) + if err != nil { + t.Fatalf("startup: %v", err) + } + rc.bind(active, func() {}) + return active +} + +// poll runs one poll trigger and returns the candidate now running (the pending swap, if any). +func (h *remoteHarness) poll(t *testing.T) *candidate { + t.Helper() + h.rc.reconcile(context.Background(), triggerPoll) + if next := h.rc.takePending(); next != nil { + h.rc.bind(next, func() {}) + } + return h.rc.current() +} + +func TestStartupReport_RedactsAndDescribes(t *testing.T) { + t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "env-secret") + h := newRemoteHarness(t, remoteConfig("apply_safe", "")+` + github: + source: ghcr.io/compliance-framework/plugin-github:v1 + enabled: false + config: + token: from-file + org: "${env:GITHUB_ORG}" + endpoint: https://bot:hunter2@git.example +`) + h.rc.lookupEnv = func(n string) (string, bool) { return "acme", n == "GITHUB_ORG" } + h.remote.publish(0, `{}`) + mustStartup(t, h.rc) + + r := h.remote.lastReport(t) + for _, doc := range []json.RawMessage{r.Base, r.Effective} { + s := string(doc) + if strings.Contains(s, "s3cret") || strings.Contains(s, "client_secret") { + t.Fatalf("client secret leaked: %s", s) + } + if strings.Contains(s, "t0ken") || strings.Contains(s, "env-secret") { + t.Fatalf("token leaked: %s", s) + } + if !strings.Contains(s, `"org":"${env:GITHUB_ORG}"`) { + t.Fatalf("placeholder must be reported as written: %s", s) + } + if strings.Contains(s, "acme") { + t.Fatalf("resolved env value leaked: %s", s) + } + if strings.Contains(s, "hunter2") { + t.Fatalf("a password in a URL must be masked by value: %s", s) + } + if !strings.Contains(s, `"github":{`) || !strings.Contains(s, `"enabled":false`) { + t.Fatalf("disabled plugin must be reported: %s", s) + } + } + var eff agentconfig.Config + if err := json.Unmarshal(r.Effective, &eff); err != nil { + t.Fatal(err) + } + if got := eff.Plugins["github"].Config["token"]; got != agentconfig.MaskedValue { + t.Fatalf("env-sourced token must be %q, got %q", agentconfig.MaskedValue, got) + } + raw, _ := json.Marshal(r) + if !strings.Contains(string(raw), `"remote-config":{"mode":"apply_safe","poll_interval":"60s","trusted_sources":["ghcr.io/trusted/*"]`) { + t.Fatalf("remote-config must be snake_case inside: %s", raw) + } + if !r.Daemon || r.Status != agentconfig.StatusApplied || r.Mode != agentconfig.ModeApplySafe { + t.Fatalf("unexpected report header %+v", r) + } + // R55: the digest is recomputable from the reported effective config, and it does not + // change when the env-sourced value rotates. + if got := agentconfig.Digest(eff, agentconfig.WithMaskedPointers("/plugins/github/config/token")); got != r.EffectiveDigest { + t.Fatalf("effective-digest %s != Digest(reported effective) %s", r.EffectiveDigest, got) + } + t.Setenv("CCF_PLUGINS_GITHUB_CONFIG_TOKEN", "rotated") + rotated := h.newReconciler() + rotated.lookupEnv = h.rc.lookupEnv + if active := mustStartup(t, rotated); active.digest != r.EffectiveDigest { + t.Fatalf("digest changed when the env value rotated: %s vs %s", active.digest, r.EffectiveDigest) + } +} + +func TestReport_ResendPolicy(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) // no document: 404 → file only + h.remote.overlay = json.RawMessage(`{}`) + h.remote.etag = `"r0-x"` + mustStartup(t, h.rc) + if h.remote.reportCount() != 1 { + t.Fatalf("expected the startup report, got %d", h.remote.reportCount()) + } + h.poll(t) + if h.remote.reportCount() != 1 { + t.Fatalf("an unchanged report must not be resent, got %d", h.remote.reportCount()) + } + h.clock.Advance(24*time.Hour + time.Second) + h.poll(t) + if h.remote.reportCount() != 2 { + t.Fatalf("expected a resend after 24h, got %d", h.remote.reportCount()) + } + h.remote.reportErr = func(int, agentconfig.Report) error { return errors.New("network down") } + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + h.poll(t) + if h.remote.reportCount() != 3 { + t.Fatalf("expected a report after a change, got %d", h.remote.reportCount()) + } + h.remote.reportErr = nil + h.poll(t) + if h.remote.reportCount() != 4 { + t.Fatalf("expected a resend after a send error, got %d", h.remote.reportCount()) + } + if r := h.remote.lastReport(t); r.AppliedRevision == nil || *r.AppliedRevision != 1 { + t.Fatalf("expected applied revision 1, got %+v", r.AppliedRevision) + } +} + +func TestModes_ReportAndOff(t *testing.T) { + t.Run("report mode never fetches", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("report", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + active := mustStartup(t, h.rc) + h.poll(t) + if h.remote.getCount() != 0 { + t.Fatalf("report mode must never fetch, got %d gets", h.remote.getCount()) + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusNotApplicable || r.AppliedRevision != nil { + t.Fatalf("report mode: %+v", r) + } + hb := buildHeartbeat(active.runtime, uuid.New(), time.Now()) + if hb.ConfigRevision == nil || *hb.ConfigRevision != 0 || hb.ConfigDigest == "" { + t.Fatalf("report mode heartbeat must carry revision 0 and a digest: %+v", hb) + } + }) + t.Run("credentials without a mode default to report", func(t *testing.T) { + // An agent that applied an overlay under an explicit apply mode... + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + if active := mustStartup(t, h.rc); active.overlay == nil { + t.Fatal("expected the overlay applied under apply_safe") + } + gets := h.remote.getCount() + + // ...restarts with credentials and no mode, with and without a remote_config block. + noBlock := strings.Replace(remoteBaseConfig, "remote_config:\n mode: %MODE%\n trusted_sources: [\"ghcr.io/trusted/*\"]\n overridable_config_flags: [%FLAGS%]\n", "", 1) + if strings.Contains(noBlock, "remote_config") { + t.Fatal("the fixture still has a remote_config block") + } + for _, tc := range []struct{ name, content string }{ + {"no remote_config block", noBlock}, + {"empty mode", remoteConfig("", "")}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(h.path, []byte(tc.content), 0o600); err != nil { + t.Fatal(err) + } + h.rc = h.newReconciler() + active := mustStartup(t, h.rc) + h.poll(t) + if active.runtime.remote.Mode != agentconfig.ModeReport { + t.Fatalf("expected mode report, got %q", active.runtime.remote.Mode) + } + if active.overlay != nil { + t.Fatalf("report mode must not apply the cached overlay, got revision %d", active.overlay.Revision) + } + if h.remote.getCount() != gets { + t.Fatalf("report mode must never fetch, got %d new gets", h.remote.getCount()-gets) + } + r := h.remote.lastReport(t) + if r.Mode != agentconfig.ModeReport || r.Status != agentconfig.StatusNotApplicable || r.AppliedRevision != nil { + t.Fatalf("expected a not-applicable report in mode report, got %+v", r) + } + }) + } + }) + t.Run("off sends nothing", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("off", "")) + active := mustStartup(t, h.rc) + h.poll(t) + if h.remote.reportCount() != 0 || h.remote.getCount() != 0 { + t.Fatalf("off must not report or fetch: %d reports, %d gets", h.remote.reportCount(), h.remote.getCount()) + } + hb := buildHeartbeat(active.runtime, uuid.New(), time.Now()) + if hb.ConfigRevision != nil || hb.ConfigDigest != "" { + t.Fatalf("off heartbeat must not carry config fields: %+v", hb) + } + }) + t.Run("no auth forces off", func(t *testing.T) { + h := newRemoteHarness(t, ` +api: + url: http://api.test +remote_config: + mode: apply_all +`) + active := mustStartup(t, h.rc) + if active.runtime.remote.Mode != agentconfig.ModeOff || h.remote.reportCount() != 0 { + t.Fatalf("expected off without auth, got %q (%d reports)", active.runtime.remote.Mode, h.remote.reportCount()) + } + }) +} + +func TestHeartbeat_FileOnlyApplySafe(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + active := mustStartup(t, h.rc) // the fake answers 404: file only + hb := buildHeartbeat(active.runtime, uuid.New(), time.Now()) + if hb.ConfigRevision == nil || *hb.ConfigRevision != 0 || hb.ConfigDigest != active.digest { + t.Fatalf("heartbeat: %+v (digest %s)", hb, active.digest) + } +} + +func TestRemoteErrors_Backoffs(t *testing.T) { + t.Run("404 backs off 10 minutes", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + mustStartup(t, h.rc) + h.poll(t) + if h.remote.getCount() != 1 { + t.Fatalf("expected no fetch during the 404 backoff, got %d", h.remote.getCount()) + } + h.clock.Advance(remoteAuthBackoff + time.Second) + h.poll(t) + if h.remote.getCount() != 2 { + t.Fatalf("expected a fetch after the backoff, got %d", h.remote.getCount()) + } + }) + for _, code := range []int{401, 403} { + t.Run(fmt.Sprintf("%d backs off 10 minutes", code), func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.getErr = &sdk.APIStatusError{StatusCode: code} + mustStartup(t, h.rc) + h.poll(t) + if h.remote.getCount() != 1 { + t.Fatalf("expected no fetch during the backoff, got %d", h.remote.getCount()) + } + h.clock.Advance(remoteAuthBackoff + time.Second) + h.poll(t) + if h.remote.getCount() != 2 { + t.Fatalf("expected a retry after the backoff, got %d", h.remote.getCount()) + } + }) + } + t.Run("409 pauses reports for an hour while polling continues", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(0, `{}`) + h.remote.reportErr = func(int, agentconfig.Report) error { return &sdk.APIStatusError{StatusCode: 409} } + mustStartup(t, h.rc) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + h.poll(t) + if h.remote.reportCount() != 1 { + t.Fatalf("expected reports paused after a 409, got %d", h.remote.reportCount()) + } + if h.remote.getCount() != 2 { + t.Fatalf("polling must continue after a 409, got %d gets", h.remote.getCount()) + } + h.clock.Advance(reportConflictBackoff + time.Second) + h.poll(t) + if h.remote.reportCount() != 2 { + t.Fatalf("expected a report after the 1h pause, got %d", h.remote.reportCount()) + } + }) + t.Run("413 resends truncated", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(0, `{}`) + h.remote.reportErr = func(n int, r agentconfig.Report) error { + if !r.Truncated { + return &sdk.APIStatusError{StatusCode: 413} + } + return nil + } + mustStartup(t, h.rc) + if h.remote.reportCount() != 2 || !h.remote.lastReport(t).Truncated { + t.Fatalf("expected a truncated resend, got %d reports", h.remote.reportCount()) + } + }) +} + +func TestReport_OversizedIsTruncated(t *testing.T) { + // config is a declared document of about n bytes. + config := func(n int) json.RawMessage { + return marshalRaw(agentconfig.Config{Plugins: map[string]*agentconfig.Plugin{ + "ssh": {Source: "ghcr.io/x/ssh:v1", Config: map[string]string{"blob": strings.Repeat("x", n)}}, + }}) + } + // A report under the target is sent whole. + report := agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(1 << 19), Effective: config(1 << 19)} + body, _, err := fitReport(&report, false) + if err != nil { + t.Fatal(err) + } + if report.Truncated || len(body) > reportTargetBytes || string(report.Base) == "{}" { + t.Fatalf("expected the report kept whole, got truncated=%v size=%d", report.Truncated, len(body)) + } + + // An oversized one drops base. + report = agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(2 << 20), Effective: config(2 << 20)} + body, _, err = fitReport(&report, false) + if err != nil { + t.Fatal(err) + } + if !report.Truncated || len(body) > agentconfig.MaxReportBytes || string(report.Base) != "{}" { + t.Fatalf("expected base dropped and a report under the limit, got truncated=%v size=%d", report.Truncated, len(body)) + } +} + +func TestReport_FileWarningStatusApplied(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")+` + bad: + source: ./plugin-bad + schedule: "not a cron" +`) + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied || len(r.Warnings) != 1 || r.Warnings[0].Path != "/plugins/bad/schedule" { + t.Fatalf("expected an applied report with one warning, got %+v", r) + } +} + +func TestSetAgentVersion(t *testing.T) { + defer SetAgentVersion("dev") + SetAgentVersion("v1.2.3") + if agentVersion != "v1.2.3" { + t.Fatalf("got %q", agentVersion) + } + SetAgentVersion("") + if agentVersion == "" { + t.Fatal("empty version must fall back") + } +} + +// --- G3: pull and apply --- + +func TestApply_BadOverlayKeepsOldConfig(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + first := mustStartup(t, h.rc) + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"bad cron"}}}`) + if got := h.poll(t); got != first { + t.Fatal("a bad overlay must keep the running config") + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonInvalidConfig || *r.AttemptedRevision != 2 || *r.AppliedRevision != 1 { + t.Fatalf("unexpected report %+v", r) + } +} + +func TestApply_DownloadFailureKeepsOldConfig(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + first := mustStartup(t, h.rc) + h.pf.setErr(errors.New("registry down")) + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"*/7 * * * *"}}}`) + if got := h.poll(t); got != first { + t.Fatal("a download failure must keep the running config") + } + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusFailed || r.Reason != agentconfig.ReasonDownloadFailed { + t.Fatalf("unexpected report %+v", r) + } +} + +func TestApply_FailedBackoff(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(0, `{}`) + mustStartup(t, h.rc) + h.pf.setErr(errors.New("registry down")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/7 * * * *"}}}`) + h.poll(t) + calls := h.pf.callCount() + h.clock.Advance(30 * time.Second) + h.poll(t) + if h.pf.callCount() != calls { + t.Fatal("a failed revision must not be retried before 1m") + } + h.clock.Advance(31 * time.Second) + h.poll(t) + if h.pf.callCount() != calls+1 { + t.Fatal("expected a retry after 1m") + } + for i := 0; i < 6; i++ { // 2m, 4m, 8m, 10m, 10m... + h.clock.Advance(failedRetryMax + time.Second) + h.poll(t) + } + if h.rc.failedInterval != failedRetryMax { + t.Fatalf("backoff must cap at %s, got %s", failedRetryMax, h.rc.failedInterval) + } +} + +func TestApply_ClassifyGate(t *testing.T) { + tests := []struct { + name string + mode string + flags string + overlay string + status string + reason string + }{ + {"api.url is forbidden", "apply_safe", "", `{"api":{"url":"http://evil"}}`, "rejected", "forbidden-changes"}, + {"api.url is forbidden in apply_all", "apply_all", "", `{"api":{"url":"http://evil"}}`, "rejected", "forbidden-changes"}, + {"new untrusted source is unsafe", "apply_safe", "", `{"plugins":{"ssh":{"source":"ghcr.io/other/plugin:v1"}}}`, "rejected", "unsafe-changes"}, + {"config change is unsafe by default", "apply_safe", "", `{"plugins":{"ssh":{"config":{"host":"other"}}}}`, "rejected", "unsafe-changes"}, + {"schedule-only change applies", "apply_safe", "", `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`, "applied", ""}, + {"trusted source applies", "apply_safe", "", `{"plugins":{"ssh":{"source":"ghcr.io/trusted/plugin:v2"}}}`, "applied", ""}, + {"unqualified overridable flag", "apply_safe", `"host"`, `{"plugins":{"ssh":{"config":{"host":"other"}}}}`, "applied", ""}, + {"plugin:key overridable flag", "apply_safe", `"ssh:host"`, `{"plugins":{"ssh":{"config":{"host":"other"}}}}`, "applied", ""}, + {"other plugin's flag does not match", "apply_safe", `"github:host"`, `{"plugins":{"ssh":{"config":{"host":"other"}}}}`, "rejected", "unsafe-changes"}, + {"star overridable flag", "apply_safe", `"*"`, `{"plugins":{"ssh":{"config":{"host":"other"}}}}`, "applied", ""}, + {"trusted new plugin with overridable keys", "apply_safe", `"*"`, `{"plugins":{"new":{"source":"ghcr.io/trusted/new:v1","config":{"a":"b"}}}}`, "applied", ""}, + {"new env ref in an overridable key", "apply_safe", `"*"`, `{"plugins":{"ssh":{"config":{"host":"${env:HOST}"}}}}`, "rejected", "unsafe-changes"}, + {"unsafe applies in apply_all", "apply_all", "", `{"plugins":{"ssh":{"config":{"host":"other"}}}}`, "applied", ""}, + {"R27 non-string config value", "apply_all", "", `{"plugins":{"ssh":{"config":{"port":2222}}}}`, "rejected", "invalid-type"}, + {"R27 unknown field", "apply_all", "", `{"evidence_capture":{}}`, "rejected", "unknown-field"}, + {"R28 mixed-case plugin name", "apply_all", "", `{"plugins":{"GitHub":{"source":"ghcr.io/trusted/gh:v1"}}}`, "rejected", "invalid-config"}, + {"inline policy bundles are not supported", "apply_all", "", `{"policy_bundles":{"ssh":{"modules":{"a.rego":"package compliance_framework.a"}}}}`, "rejected", "unknown-field"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig(tt.mode, tt.flags)) + h.remote.publish(1, tt.overlay) + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if r.Status != tt.status || r.Reason != tt.reason { + t.Fatalf("got %s/%s (%v), want %s/%s", r.Status, r.Reason, derefString(r.Error), tt.status, tt.reason) + } + if tt.reason == "unsafe-changes" && len(r.Unsafe) == 0 { + t.Fatal("expected the unsafe changes to be listed") + } + }) + } +} + +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +func TestApply_404FallsBackToCacheThenFile(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + mustStartup(t, h.rc) + + h.remote.overlay = nil // the API now answers 404 + restarted := h.newReconciler() + active := mustStartup(t, restarted) + if active.overlay == nil || active.overlay.Revision != 1 { + t.Fatalf("expected the cached overlay after a 404, got %+v", active.overlay) + } + + if err := os.Remove(filepath.Join(h.dir, "state", "remote-config.json")); err != nil { + t.Fatal(err) + } + fileOnly := mustStartup(t, h.newReconciler()) + if fileOnly.overlay != nil { + t.Fatalf("expected the file only without a cache, got %+v", fileOnly.overlay) + } +} + +func TestApply_OpaqueETag(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(3, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + mustStartup(t, h.rc) + firstETag := h.remote.etag + h.poll(t) + if got := h.remote.gets[len(h.remote.gets)-1]; got != firstETag { + t.Fatalf("If-None-Match must be the raw ETag %q, got %q", firstETag, got) + } + // The API is reset and reuses revision 3 with another overlay and a new ETag. + h.remote.publish(3, `{"plugins":{"ssh":{"schedule":"*/9 * * * *"}}}`) + if got := *h.poll(t).runtime.Plugins["ssh"].Schedule; got != "*/9 * * * *" { + t.Fatalf("a new ETag with a reused revision must be re-evaluated, got schedule %q", got) + } + for _, sent := range h.remote.gets { + if sent == "3" || sent == `"3"` { + t.Fatalf("the agent must never build an ETag from a revision, sent %q", sent) + } + } +} + +func TestCache_IdentityCorruptionAndMode(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + mustStartup(t, h.rc) + cachePath := filepath.Join(h.dir, "state", "remote-config.json") + info, err := os.Stat(cachePath) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("cache mode = %v, want 0600", info.Mode().Perm()) + } + + t.Run("identity mismatch discards the cache", func(t *testing.T) { + store := agentstate.Open(filepath.Join(h.dir, "state"), nil) + c, err := store.LoadCache(agentstate.Identity{APIURL: "http://other", ClientID: "x"}) + if err != nil || c.Applied != nil || c.Fetched != nil { + t.Fatalf("expected an empty cache, got %+v, %v", c, err) + } + }) + + t.Run("corruption is reported and the agent continues", func(t *testing.T) { + raw, err := os.ReadFile(cachePath) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cachePath, []byte(strings.Replace(string(raw), `"revision": 1`, `"revision": 9`, 1)), 0o600); err != nil { + t.Fatal(err) + } + h.remote.overlay = nil // 404: nothing to fetch + active := mustStartup(t, h.newReconciler()) + if active.overlay != nil { + t.Fatalf("a corrupt cache must not be applied, got %+v", active.overlay) + } + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusFailed || r.Reason != agentconfig.ReasonCacheCorrupt { + t.Fatalf("expected failed/cache-corrupt, got %s/%s", r.Status, r.Reason) + } + }) +} + +func TestApply_RejectedRevisionMemory(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"source":"ghcr.io/other/plugin:v1"}}}`) + mustStartup(t, h.rc) + if r := h.remote.lastReport(t); r.Reason != agentconfig.ReasonUnsafeChanges { + t.Fatalf("expected unsafe-changes, got %s", r.Reason) + } + calls := h.pf.callCount() + h.poll(t) + h.poll(t) + if h.pf.callCount() != calls { + t.Fatalf("a remembered rejection must not be re-prepared (%d prefetches)", h.pf.callCount()-calls) + } + // A base edit that makes the source "already used" re-classifies and applies. + h.writeConfig(t, remoteConfig("apply_safe", "")+` + other: + source: ghcr.io/other/plugin:v1 +`) + h.rc.reconcile(context.Background(), triggerFile) + if next := h.rc.takePending(); next == nil || next.overlay == nil || next.overlay.Revision != 1 { + t.Fatalf("expected revision 1 to apply after the base edit, got %+v", next) + } +} + +// TestApply_RememberedRejectionReportedAfterRestart: a restart that skips a remembered +// rejection (the fetch answers 304) still reports it, and a later re-prepare of the applied +// overlay (a file edit with the same fingerprint) does not clear it. +func TestApply_RememberedRejectionReportedAfterRestart(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + mustStartup(t, h.rc) + h.remote.publish(2, `{"plugins":{"ssh":{"source":"ghcr.io/other/plugin:v1"}}}`) + h.poll(t) + before := h.remote.lastReport(t) + if before.Status != agentconfig.StatusRejected || len(before.Unsafe) == 0 { + t.Fatalf("expected revision 2 to be rejected with unsafe changes, got %+v", before) + } + + assertRejected := func(t *testing.T, r agentconfig.Report) { + t.Helper() + if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonUnsafeChanges { + t.Fatalf("expected rejected/unsafe-changes, got %s/%s", r.Status, r.Reason) + } + if r.AttemptedRevision == nil || *r.AttemptedRevision != 2 { + t.Fatalf("expected attempted revision 2, got %v", r.AttemptedRevision) + } + if r.AppliedRevision == nil || *r.AppliedRevision != 1 { + t.Fatalf("expected applied revision 1, got %v", r.AppliedRevision) + } + if derefString(r.Error) != derefString(before.Error) { + t.Fatalf("expected error %q, got %q", derefString(before.Error), derefString(r.Error)) + } + if !reflect.DeepEqual(r.Unsafe, before.Unsafe) { + t.Fatalf("expected the persisted unsafe changes %+v, got %+v", before.Unsafe, r.Unsafe) + } + } + + gets, reports := h.remote.getCount(), h.remote.reportCount() + h.rc = h.newReconciler() + calls := h.pf.callCount() + if a := mustStartup(t, h.rc); a.overlay == nil || a.overlay.Revision != 1 { + t.Fatalf("expected the applied revision 1, got %+v", a.overlay) + } + if h.remote.getCount() != gets+1 || h.remote.gets[len(h.remote.gets)-1] != h.remote.etag { + t.Fatal("the restart must fetch conditionally (304)") + } + if h.remote.reportCount() != reports+1 { + t.Fatal("the restart must send a report") + } + assertRejected(t, h.remote.lastReport(t)) + if got := h.pf.callCount() - calls; got != 1 { + t.Fatalf("only the applied revision may be prepared, got %d prepares", got) + } + + h.poll(t) + assertRejected(t, h.remote.lastReport(t)) + + // A comment changes the file but not its fingerprint: the applied overlay is re-prepared + // and the remembered rejection stays the outcome. + h.writeConfig(t, remoteConfig("apply_safe", "")+"# edited\n") + calls = h.pf.callCount() + h.rc.reconcile(context.Background(), triggerFile) + if h.pf.callCount() == calls { + t.Fatal("the file edit must re-prepare the applied revision") + } + assertRejected(t, h.remote.lastReport(t)) +} + +func TestStartupLadder(t *testing.T) { + t.Run("fetched wins", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + if a := mustStartup(t, h.rc); a.overlay == nil || a.overlay.Revision != 2 { + t.Fatalf("got %+v", a.overlay) + } + }) + t.Run("rejected fetched falls back to applied", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + mustStartup(t, h.rc) + h.remote.publish(2, `{"plugins":{"ssh":{"source":"ghcr.io/other/plugin:v1"}}}`) + a := mustStartup(t, h.newReconciler()) + if a.overlay == nil || a.overlay.Revision != 1 { + t.Fatalf("expected the applied revision 1, got %+v", a.overlay) + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusRejected || *r.AttemptedRevision != 2 || *r.AppliedRevision != 1 { + t.Fatalf("unexpected report %+v", r) + } + }) + t.Run("failing fetched and applied fall back to the file", func(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + mustStartup(t, h.rc) + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"*/7 * * * *"}}}`) + h.pf.setErr(errors.New("registry down")) + restarted := h.newReconciler() + if _, err := restarted.startup(context.Background()); err == nil { + t.Fatal("when even the file cannot be prepared startup must fail") + } + h.pf.setErr(nil) + }) + t.Run("unusable file fails", func(t *testing.T) { + h := newRemoteHarness(t, "api: {}\n") + if _, err := h.rc.startup(context.Background()); err == nil { + t.Fatal("expected an error") + } + }) +} + +func TestEnvPlaceholders(t *testing.T) { + // ${env:} is only resolved in plugins.*.config (R24): in the file's policy_data it is a + // literal passed through unchanged with a warning (R34, as on main), and an overlay using + // it there is rejected. + lenient := newRemoteHarness(t, remoteConfig("apply_all", "")+` + policy_data: + url: "${env:NOT_RESOLVED}" +`) + started, err := lenient.rc.startup(context.Background()) + if err != nil { + t.Fatalf("a file policy_data placeholder must not be fatal: %v", err) + } + if got := started.runtime.Plugins["ssh"].PolicyData["url"]; got != "${env:NOT_RESOLVED}" { + t.Fatalf("policy_data must be passed through unchanged, got %v", got) + } + if r := lenient.remote.lastReport(t); len(r.Warnings) != 1 || r.Warnings[0].Code != agentconfig.FieldCodeEnvLocation { + t.Fatalf("expected one env-location warning, got %+v", r.Warnings) + } + + h := newRemoteHarness(t, remoteConfig("apply_all", "")) + env := map[string]string{"HOST": "db.internal", "PORT": "5432"} + h.rc.lookupEnv = func(n string) (string, bool) { v, ok := env[n]; return v, ok } + h.remote.publish(1, `{"plugins":{"ssh":{"policy_data":{"url":"${env:HOST}"}}}}`) + mustStartup(t, h.rc) + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonInvalidConfig { + t.Fatalf("expected an overlay policy_data placeholder to be rejected, got %s/%s", r.Status, r.Reason) + } + + h.remote.publish(2, `{"plugins":{"ssh":{"config":{"host":"${env:HOST}","dsn":"pg://${env:HOST}:${env:PORT}/db"}}}}`) + active := h.poll(t) + cfg := active.runtime.Plugins["ssh"].Config + if cfg["host"] != "db.internal" || cfg["dsn"] != "pg://db.internal:5432/db" { + t.Fatalf("placeholders not resolved whole/embedded: %#v", cfg) + } + var eff agentconfig.Config + if err := json.Unmarshal(h.remote.lastReport(t).Effective, &eff); err != nil { + t.Fatal(err) + } + reported := eff.Plugins["ssh"].Config + if reported["host"] != "${env:HOST}" { + t.Fatalf("the report must carry the unresolved placeholder, got %#v", reported) + } + // Literal text mixed with a placeholder under a secret-like key is masked; the API's + // agentconfig redaction is the source of truth. + if reported["dsn"] != agentconfig.MaskedValue { + t.Fatalf("a dsn mixing literal text and placeholders must be masked, got %#v", reported) + } + digest := active.digest + env["HOST"] = "rotated" + restarted := h.newReconciler() + restarted.lookupEnv = h.rc.lookupEnv + if again := mustStartup(t, restarted); again.digest != digest || again.overlay == nil { + t.Fatal("the digest must not change when an env value changes") + } + + delete(env, "PORT") + h.remote.publish(3, `{"plugins":{"ssh":{"config":{"host":"${env:HOST}","dsn":"pg://${env:PORT}"}}}}`) + h.rc.lookupEnv = func(n string) (string, bool) { v, ok := env[n]; return v, ok } + h.poll(t) + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusFailed || r.Reason != agentconfig.ReasonEnvMissing { + t.Fatalf("expected failed/env-missing, got %s/%s", r.Status, r.Reason) + } + if !strings.Contains(*r.Error, "PORT") || strings.Contains(*r.Error, "rotated") { + t.Fatalf("the error must name the variable, never values: %q", *r.Error) + } +} + +// TestEnvPlaceholders_FileOriginUnsetIsWarning pins R60: an unset variable the FILE references +// is a warning and the literal reaches the plugin unchanged (as on main); an unset variable the +// overlay introduces still fails with failed/env-missing. +func TestEnvPlaceholders_FileOriginUnsetIsWarning(t *testing.T) { + content := strings.Replace(remoteConfig("apply_all", ""), "token: t0ken", "token: \"${env:UNSET_TOKEN}\"\n dsn: \"pg://${env:DB_HOST}/x\"", 1) + h := newRemoteHarness(t, content) + env := map[string]string{"DB_HOST": "db.internal"} + h.rc.lookupEnv = func(n string) (string, bool) { v, ok := env[n]; return v, ok } + h.remote.publish(1, `{}`) + + active := mustStartup(t, h.rc) + cfg := active.runtime.Plugins["ssh"].Config + if cfg["token"] != "${env:UNSET_TOKEN}" || cfg["dsn"] != "pg://db.internal/x" { + t.Fatalf("expected the unset literal unchanged and the set one resolved, got %#v", cfg) + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied || len(r.Warnings) != 1 || r.Warnings[0].Path != "/plugins/ssh/config/token" || r.Warnings[0].Code != agentconfig.FieldCodeEnvMissing { + t.Fatalf("expected applied with one env-missing warning, got %s %+v", r.Status, r.Warnings) + } + + h.remote.publish(2, `{"plugins":{"ssh":{"config":{"extra":"${env:NEW_UNSET}"}}}}`) + h.poll(t) + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusFailed || r.Reason != agentconfig.ReasonEnvMissing { + t.Fatalf("an overlay-introduced unset variable must fail with env-missing, got %s/%s", r.Status, r.Reason) + } + + // Per (pointer, variable): the overlay rewrites the value but the variable is the file's. + h.remote.publish(3, `{"plugins":{"ssh":{"config":{"token":"x-${env:UNSET_TOKEN}"}}}}`) + next := h.poll(t) + if got := next.runtime.Plugins["ssh"].Config["token"]; got != "x-${env:UNSET_TOKEN}" || next.appliedRevision() == nil || *next.appliedRevision() != 3 { + t.Fatalf("expected revision 3 applied with the literal unchanged, got %q", got) + } +} + +func TestOneShot_FetchApplyReportRun(t *testing.T) { + h := newRemoteHarness(t, strings.Replace(remoteConfig("apply_safe", ""), "daemon: true", "daemon: false", 1)) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + active, err := h.rc.startup(context.Background()) + if err != nil { + t.Fatal(err) + } + if h.remote.reportCount() != 1 || h.remote.lastReport(t).Daemon { + t.Fatalf("one-shot must report once with daemon=false before running") + } + runs := 0 + err = h.rc.run(active, func(_ context.Context, cfg *agentConfig) error { + runs++ + if *cfg.Plugins["ssh"].Schedule != "*/5 * * * *" { + t.Fatalf("the overlay was not applied") + } + return nil + }) + if err != nil || runs != 1 { + t.Fatalf("one-shot must run once and exit: runs=%d err=%v", runs, err) + } +} + +func TestReconciler_RaceInterleavedFileEventsAndPolls(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/1 * * * *"}}}`) + h.rc.now = time.Now + active, err := h.rc.startup(context.Background()) + if err != nil { + t.Fatal(err) + } + var mu sync.Mutex + running := 0 + maxRunning := 0 + var last *agentConfig + stop := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- h.rc.run(active, func(ctx context.Context, cfg *agentConfig) error { + mu.Lock() + running++ + maxRunning = max(maxRunning, running) + last = cfg + mu.Unlock() + defer func() { + mu.Lock() + running-- + mu.Unlock() + }() + select { + case <-ctx.Done(): + return nil + case <-stop: + return errStopRun + } + }) + }() + t.Cleanup(func() { + // Stop rc.run (it returns once the run and its fallback both stop) so the goroutine + // does not leak into other tests. + close(stop) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("rc.run did not return") + } + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + for i := 2; i <= 200; i++ { + h.remote.publish(int64(i), fmt.Sprintf(`{"plugins":{"ssh":{"schedule":"*/%d * * * *"}}}`, i%59+1)) + h.rc.reconcile(ctx, triggerPoll) + if i%10 == 0 { + h.rc.reconcile(ctx, triggerFile) + } + } + want := fmt.Sprintf("*/%d * * * *", 200%59+1) + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + mu.Lock() + ok := last != nil && *last.Plugins["ssh"].Schedule == want + mu.Unlock() + if ok { + break + } + time.Sleep(5 * time.Millisecond) + } + mu.Lock() + defer mu.Unlock() + if maxRunning != 1 { + t.Fatalf("expected exactly one active run at a time, saw %d", maxRunning) + } + if last == nil || *last.Plugins["ssh"].Schedule != want { + t.Fatalf("the last revision must win") + } +} + +// TestApply_NoOpRevisionAdoptedWithoutRestart: a revision whose effective config equals the +// running one is recorded as applied without a restart, and is not re-prepared every poll. +func TestApply_NoOpRevisionAdoptedWithoutRestart(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + first := mustStartup(t, h.rc) + + // verbosity 0 equals the base: the effective config does not change. + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}},"verbosity":0}`) + calls := h.pf.callCount() + var cur *candidate + for i := 0; i < 3; i++ { + cur = h.poll(t) + } + if got := h.pf.callCount() - calls; got != 1 { + t.Fatalf("a no-op revision must be prepared once, got %d prefetches", got) + } + if cur.runtime != first.runtime { + t.Fatal("a no-op revision must not restart the running configuration") + } + if rev := cur.runtime.syncInfo().AppliedRevision; rev != 2 { + t.Fatalf("the heartbeat/evidence must show applied revision 2, got %d", rev) + } + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusApplied || r.AppliedRevision == nil || *r.AppliedRevision != 2 { + t.Fatalf("the report must show applied revision 2, got %+v", r) + } + + // A comment-only file edit is adopted the same way. + h.writeConfig(t, "# a comment\n"+remoteConfig("apply_safe", "")) + h.rc.reconcile(context.Background(), triggerFile) + calls = h.pf.callCount() + for i := 0; i < 3; i++ { + cur = h.poll(t) + } + if h.pf.callCount() != calls || cur.runtime != first.runtime { + t.Fatalf("a comment-only edit must not be re-prepared every poll (%d prefetches) nor restart", h.pf.callCount()-calls) + } +} + +// TestApply_RejectedAppliedOverlayNotRePrepared: after a file edit makes the applied overlay +// invalid, the remembered rejection keeps last-known-good instead of re-preparing every poll. +func TestApply_RejectedAppliedOverlayNotRePrepared(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", `"host"`)) + var logs bytes.Buffer + h.rc.logger = hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Warn}) + h.remote.publish(1, `{"plugins":{"ssh":{"config":{"host":"other"}}}}`) + first := mustStartup(t, h.rc) + if first.overlay == nil { + t.Fatal("revision 1 must apply while host is overridable") + } + + h.writeConfig(t, remoteConfig("apply_safe", "")) // host is no longer overridable + h.rc.reconcile(context.Background(), triggerFile) + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonUnsafeChanges { + t.Fatalf("expected rejected/unsafe-changes, got %s/%s", r.Status, r.Reason) + } + prepares := strings.Count(logs.String(), "Could not apply the configuration") + for i := 0; i < 3; i++ { + if got := h.poll(t); got != first { + t.Fatal("the last-known-good configuration must keep running") + } + } + if got := strings.Count(logs.String(), "Could not apply the configuration") - prepares; got != 0 { + t.Fatalf("a remembered rejection must not be re-prepared, got %d prepares", got) + } +} + +// TestApply_EmptyETagDoesNotBlockLaterRevisions: without an ETag, revisions are told apart by +// revision + overlay bytes. +func TestApply_EmptyETagDoesNotBlockLaterRevisions(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"source":"ghcr.io/other/plugin:v1"}}}`) + h.remote.etag = "" + mustStartup(t, h.rc) + if r := h.remote.lastReport(t); r.Reason != agentconfig.ReasonUnsafeChanges { + t.Fatalf("expected unsafe-changes, got %s", r.Reason) + } + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + h.remote.etag = "" + if got := h.poll(t); got.overlay == nil || got.overlay.Revision != 2 { + t.Fatalf("revision 2 must apply despite the empty ETag, got %+v", got.overlay) + } +} + +// TestApply_RunFailureNotifiesAfterFallbackIsBound: onRunFailed sees the fallback as current, +// so the applied overlay reverts to it and the report describes it. +func TestApply_RunFailureNotifiesAfterFallbackIsBound(t *testing.T) { + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + active, err := h.rc.startup(context.Background()) + if err != nil { + t.Fatal(err) + } + stop := make(chan struct{}) + done := make(chan error, 1) + go func() { + done <- h.rc.run(active, func(ctx context.Context, cfg *agentConfig) error { + if *cfg.Plugins["ssh"].Schedule == "*/7 * * * *" { + return errors.New("failed to start") + } + select { + case <-ctx.Done(): + return nil + case <-stop: + return errStopRun + } + }) + }() + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"*/7 * * * *"}}}`) + h.rc.reconcile(context.Background(), triggerPoll) + + var failedRun *candidate + select { + case failedRun = <-h.rc.runFailed: + case <-time.After(5 * time.Second): + t.Fatal("the run failure was never notified") + } + if cur := h.rc.current(); cur == nil || cur.overlay == nil || cur.overlay.Revision != 1 { + t.Fatalf("the fallback must be bound before the notification, current is %+v", cur) + } + h.rc.onRunFailed(context.Background(), failedRun) + if a := h.rc.cache.Applied; a == nil || a.Revision != 1 { + t.Fatalf("the applied overlay must revert to revision 1, got %+v", a) + } + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusFailed || r.AppliedRevision == nil || *r.AppliedRevision != 1 { + t.Fatalf("the failure report must describe the fallback, got %+v", r) + } + close(stop) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("run did not return") + } +} + +// TestApply_PrefetchIsBounded: a hanging registry does not stall the reconciler. +func TestApply_PrefetchIsBounded(t *testing.T) { + old := prepareNetworkTimeout + prepareNetworkTimeout = 50 * time.Millisecond + t.Cleanup(func() { prepareNetworkTimeout = old }) + + h := newRemoteHarness(t, remoteConfig("apply_safe", "")) + h.remote.publish(1, `{"plugins":{"ssh":{"schedule":"*/5 * * * *"}}}`) + first := mustStartup(t, h.rc) + h.pf.setBlock(true) + h.remote.publish(2, `{"plugins":{"ssh":{"schedule":"*/7 * * * *"}}}`) + start := time.Now() + if got := h.poll(t); got != first { + t.Fatal("a hung download must keep the running config") + } + if time.Since(start) > 5*time.Second { + t.Fatalf("prepare was not bounded: %s", time.Since(start)) + } + if r := h.remote.lastReport(t); r.Status != agentconfig.StatusFailed || r.Reason != agentconfig.ReasonDownloadFailed { + t.Fatalf("expected failed/download-failed, got %s/%s", r.Status, r.Reason) + } +} diff --git a/cmd/report.go b/cmd/report.go new file mode 100644 index 0000000..d2dbc4e --- /dev/null +++ b/cmd/report.go @@ -0,0 +1,208 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "maps" + "net/http" + "os" + "runtime/debug" + "slices" + "strings" + "time" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" +) + +// reportTargetBytes is the size the agent aims for; the API limit is agentconfig.MaxReportBytes. +const reportTargetBytes = 3*(1<<20) + (1 << 19) // 3.5 MiB + +// agentVersion is the agent build version reported to the API (see SetAgentVersion). +var agentVersion = "dev" + +// SetAgentVersion sets the version reported in config reports. main passes the goreleaser +// ldflag value; "dev" (or empty) falls back to the module version for `go install` builds. +func SetAgentVersion(v string) { + v = strings.TrimSpace(v) + if v == "" || v == "dev" { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" { + v = info.Main.Version + } else { + v = "dev" + } + } + agentVersion = v +} + +// pluginLibFunc returns the agent library version the binary of a plugin source was built +// with ("" when unknown). The source has been prefetched. +type pluginLibFunc func(ctx context.Context, source string) (string, error) + +// pluginReports lists the plugins of runtime with the agent library each was built with (R76), +// read from the plugin binary's build info: diagnostics for the UI. A version that cannot be +// read is reported as unknown (empty). Without a pluginLib function it reports nothing. +func (rc *reconciler) pluginReports(ctx context.Context, runtime *agentConfig) []agentconfig.PluginReport { + if rc.pluginLib == nil || runtime == nil { + return nil + } + var reports []agentconfig.PluginReport + for _, name := range slices.Sorted(maps.Keys(runtime.Plugins)) { + p := runtime.Plugins[name] + version, err := rc.pluginLib(ctx, p.Source) + if err != nil { + version = "" + if rc.logOnce("plugin-lib\x00" + p.Source + "\x00" + err.Error()) { + rc.logger.Warn("Could not read the agent library version of a plugin; reporting it as unknown", "plugin", name, "source", p.Source, "error", err) + } + } + reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version}) + } + return reports +} + +// reportState is the reconciler's report bookkeeping. +type reportState struct { + fingerprint string // sha256 of the last report sent successfully + sentAt time.Time // when it was sent + sendFailed bool // the last attempt failed; retry on the next tick +} + +// maybeReport sends a config report for active when it differs from the last one sent, after a +// send error, or when the last one is older than 24h (G2.2). Mode off never reports. +func (rc *reconciler) maybeReport(ctx context.Context, active *candidate, outcome *applyError) { + if active == nil || rc.remote == nil { + return + } + rcfg := rc.rcfg() + if rcfg.Mode == agentconfig.ModeOff { + return + } + if rc.now().Before(rc.reportBackoffUntil) { + return + } + report := rc.buildReport(active, outcome, rcfg) + body, fingerprint, err := fitReport(&report, false) + if err != nil { + rc.logger.Error("Could not encode the config report", "error", err) + return + } + if !rc.report.sendFailed && fingerprint == rc.report.fingerprint && rc.now().Sub(rc.report.sentAt) < reportResendInterval { + return + } + if len(body) > agentconfig.MaxReportBytes { + rc.logger.Warn("Config report exceeds the API limit even after truncation", "bytes", len(body)) + } + + err = rc.sendReport(ctx, report) + var statusErr *sdk.APIStatusError + if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusRequestEntityTooLarge { + rc.logger.Warn("The API rejected the config report as too large; resending it truncated") + if _, _, ferr := fitReport(&report, true); ferr == nil { + err = rc.sendReport(ctx, report) + } + } + switch { + case err == nil: + rc.report = reportState{fingerprint: fingerprint, sentAt: rc.now()} + if outcome != nil && outcome.Reason == agentconfig.ReasonCacheCorrupt && rc.lastOutcome == outcome { + rc.lastOutcome = nil // reported once + } + case errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusConflict: + rc.report.sendFailed = true + rc.reportBackoffUntil = rc.now().Add(reportConflictBackoff) + rc.logger.Warn("The API refused the config report: the agent's instance cap is reached; pausing reports", "retry_in", reportConflictBackoff, "error", err) + default: + rc.report.sendFailed = true + rc.handleRemoteError("report", err, &rc.reportBackoffUntil) + } +} + +func (rc *reconciler) sendReport(ctx context.Context, report agentconfig.Report) error { + reportCtx, cancel := context.WithTimeout(ctx, remoteRequestTimeout) + defer cancel() + return rc.remote.Report(reportCtx, rc.instanceID, report) +} + +// buildReport fills the wire report for the active candidate and the last outcome (G2.2). +// base and effective are the UNRESOLVED forms, redacted with the same masked pointers the +// digest uses (R24, R25, R55). +func (rc *reconciler) buildReport(active *candidate, outcome *applyError, rcfg agentconfig.RemoteConfig) agentconfig.Report { + hostname, _ := os.Hostname() + opts := active.base.redactOpts() + report := agentconfig.Report{ + Hostname: truncateString(hostname, 255), + AgentVersion: truncateString(agentVersion, 64), + Mode: rcfg.Mode, + Daemon: active.runtime.Daemon, + AppliedRevision: active.appliedRevision(), + AttemptedRevision: rc.attempted, + Base: marshalRaw(agentconfig.Redact(active.base.declared, opts...)), + Effective: marshalRaw(agentconfig.Redact(active.declared, opts...)), + EffectiveDigest: active.digest, + Warnings: active.warnings, + RemoteConfig: &rcfg, + Plugins: active.plugins, + } + switch { + case !isApplyMode(rcfg.Mode): + report.Status = agentconfig.StatusNotApplicable + report.AttemptedRevision = nil + case outcome != nil: + report.Status = outcome.Status + report.Reason = outcome.Reason + msg := outcome.Reason + if outcome.Err != nil { + msg = outcome.Err.Error() + } + report.Error = &msg + report.Unsafe = outcome.Unsafe + default: + report.Status = agentconfig.StatusApplied + } + return report +} + +func marshalRaw(c agentconfig.Config) json.RawMessage { + raw, err := json.Marshal(c) + if err != nil { + return json.RawMessage(`{}`) + } + return raw +} + +func truncateString(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +// fitReport encodes the report, shrinking it to reportTargetBytes when needed (or always when +// force is set, for a resend after a 413): base is dropped, which sets Truncated. It returns the body and its fingerprint. +func fitReport(report *agentconfig.Report, force bool) ([]byte, string, error) { + body, err := json.Marshal(report) + if err != nil { + return nil, "", err + } + steps := []func(*agentconfig.Report){dropReportBase} + for _, step := range steps { + if !force && len(body) <= reportTargetBytes { + break + } + step(report) + report.Truncated = true + if body, err = json.Marshal(report); err != nil { + return nil, "", err + } + } + sum := sha256.Sum256(body) + return body, hex.EncodeToString(sum[:]), nil +} + +func dropReportBase(report *agentconfig.Report) { + report.Base = json.RawMessage(`{}`) +} diff --git a/cmd/submit_evidence.go b/cmd/submit_evidence.go index 853ceab..16c0b5f 100644 --- a/cmd/submit_evidence.go +++ b/cmd/submit_evidence.go @@ -363,7 +363,7 @@ func submitEvidenceAPIConfig(apiURLFlag string) (*apiConfig, error) { if config.Auth.ClientID == "" && config.Auth.ClientSecret == "" { config.Auth = nil } - if err := config.validate(); err != nil { + if err := validateAPIConfig(config); err != nil { return nil, err } return config, nil diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md new file mode 100644 index 0000000..83fef82 --- /dev/null +++ b/docs/adr/0003-remote-config-overlay.md @@ -0,0 +1,114 @@ +# ADR 0003: Remote configuration overlay + +- Date: 2026-09-30 +- ADR 0002 is reserved for evidence-v3. + +## Context + +Operators want to see the configuration each agent is running and to change it from the API (new schedules, plugin +config, policy data, policy sources) without logging in to every host. The local config file must stay the bootstrap and +the host owner's control: the API connection, the daemon flag and the remote-configuration policy itself must never be +changeable remotely, and a bad remote change must never take a working agent down. + +The shared configuration model lives in the API module (`api/pkg/agentconfig`), so the agent, the API's validation and +the UI preview classify and validate changes the same way. + +## Decision + +### Declared and runtime forms + +`agentconfig.Config` is the *declared* form: what is decoded, merged (RFC 7396), classified, validated, redacted, +digested and reported. The agent's existing private structs remain the *runtime* form, built by one `toRuntime` +conversion. Type aliases were not possible (methods on the structs, an unexported field, and many tests), and keeping +the runtime form keeps `agentConfigurationHash`, and so evidence identity, byte-identical. + +The file is decoded through viper's weak decoder exactly as before (R51). Only a remote overlay is decoded strictly +(`ValidateOverlay`): unknown keys and wrongly-typed values reject the revision (R27). + +### Prepare, then cancel + +One reconciler goroutine serializes every trigger (config file change, poll). A trigger builds a complete candidate: +overlay validation, the `Classify` gate, merge, validation, `${env:}` resolution, and every download (`Prefetch`). Only then is the running configuration cancelled. Any failure leaves the +running configuration untouched and is reported. Each network step of a prepare is bounded (5 minutes), so a hung +registry is a `download-failed`, not a stalled reconciler. In-flight plugin runs drain for up to 5 minutes on a swap; a +SIGINT/SIGTERM during the drain still exits within 30 seconds. A run that fails on its own after a swap falls back to +the previous configuration and that candidate (overlay or file-only) enters the failed backoff, so it is not re-applied +on every poll. A candidate whose effective configuration equals the running one (a no-op revision, a comment-only file +edit) is recorded as applied without a restart: the heartbeat, evidence and report show its revision. An applied +overlay that a file edit makes invalid is remembered as rejected and the last good configuration keeps running. +Startup tries the fetched overlay, +then the cached applied overlay, then the file alone; only an unusable file exits (a download failure of the file +alone still sends the startup-failure agent evidence first, as before). + +### Applying is opt-in + +With `api.auth` credentials and no `remote_config.mode`, the mode is `report` (R29, `agentconfig.RemoteConfig.Normalize` +in the API): the agent reports its configuration but never fetches or applies an overlay. The host owner opts in to +remote changes with `apply_safe` or `apply_all`. In `report` mode a cached applied overlay is not applied either. + +### The agent is the Classify authority + +The API validates and previews, but the agent classifies every revision against its own base and its own +`remote_config` before applying it (`agentconfig.Classify` + `WillApply`). Forbidden changes (the locked keys, local +sources, `${env:CCF_API_AUTH_*}`) reject the whole revision in every mode (R23). Nothing touches the network before the +gate passes. + +### Inline policy bundles are out of scope + +Inline policy bundles (authoring, overriding or deleting policy modules through the config file or an overlay) are not +part of this design (decision of 2026-10-02): keeping vendor evidence streams, checking the policy contract and +sandboxing the checks made them the largest and riskiest part of the change. Policies +reach plugins only as OCI or local sources, which an overlay may add, remove or reorder under the `Classify` rules. An +overlay that sets `policy_bundles` is rejected with `unknown-field`, and the report has no policy errors. + +### Plugin library versions (R76) + +What a plugin does with a policy depends on the `policy-manager` compiled into it, not on the running agent. The agent +reads the `github.com/compliance-framework/agent` version from each plugin binary with `debug/buildinfo.ReadFile` +(memoized by path, size and modification time) after prefetch and reports it (`plugins[].lib-version`) as diagnostics. +Nothing is gated on it. A `replace` or devel build reports an empty version. + +### Plugin environment filter + +go-plugin hands the whole host environment to plugins. The agent now sets `SkipHostEnv` and passes the host +environment minus `CCF_API_AUTH_*`, so plugins never see the agent's API credentials; cloud credentials, `PATH`, +`HOME` and the rest still pass through (R26). + +### Opaque ETag + +The overlay ETag is opaque (`"r-"`). The agent stores the raw header in its cache and sends it back +verbatim as `If-None-Match`; it never builds one from a revision number, so a reset or recreated API can never produce +a false 304 (R7). The cache is bound to `api.url` and `client_id`, and a rejected revision is remembered per +(ETag, base fingerprint), never per revision number alone. A response without an ETag (a stripping proxy) is keyed by +revision + sha256 of the overlay instead, so one rejection never blocks later revisions. The remembered rejection keeps +its status, reason, error and unsafe changes, so the agent re-reports it after a restart and while the fetch keeps +answering 304. + +### File-origin tolerance (R34) + +The shared `Validate` is stricter than the agent used to be in one way: it parses `schedule`. A bad schedule in the +file used to be only logged, and the plugin never ran. To stay non-breaking, a validation error is attributed by +origin: an error at a pointer the overlay touched (equal, prefix or extension, segment-wise) is overlay-origin and +rejects the revision; otherwise it is file-origin. File-origin errors on the closed tolerated list (only +`/plugins/

/schedule`) become reported warnings and the plugin is skipped. A second, warn-only list covers values +that load on `main` with a meaning the agent keeps: a negative `verbosity` (hclog Warn) and a literal `${env:...}` +outside `plugins.*.config`. They are reported as warnings; nothing is +skipped and the value is unchanged. Every other file-origin error stays fatal (startup exit 1, or last-known-good on +reload). Overlay-origin errors are always strict. + +### Unset `${env:}` in the file (R60) + +Owner decision (2026-09-30, review of agent#95): R24 resolves `${env:NAME}` in the file's `plugins.*.config` too, but +an unset variable that the file references is a **warning** and the literal value is passed to the plugin unchanged, +exactly as on `main`, where placeholders were never resolved. "File-origin" is decided per (pointer, variable): the +base's value at that pointer references the variable. An unset variable that the overlay introduces still fails the +revision with `failed/env-missing`. + +## Consequences + +- Reports never carry resolved secrets: base and effective are the unresolved forms, redacted with the same masked + pointers the effective digest uses (R24, R25, R55). The redaction rules (secret-like keys and values) are the API's + `pkg/agentconfig`; the agent does not re-implement them. +- The default state directory depends on the config path; containers must pin `CCF_STATE_DIR` (R52). +- A new agent that does not understand a newer overlay key rejects the revision with `unknown-field`, visible in the UI. +- Known limit: viper stops watching the config file after a `Remove` event (follow-up). diff --git a/docs/configuration.md b/docs/configuration.md index e762ef9..2832e23 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -154,7 +154,150 @@ periodic agent evidence while keeping `emit_on_run_completion` behavior enabled. no expiry. Set `agent_evidence.emit_on_run_completion` to `false` to disable immediate agent evidence on run completion and startup failures while leaving periodic daemon evidence controlled by `interval`. -The `log_level` is one of the following, defaulting to `0` if not specified: +The `log_level` is one of the following, defaulting to `0` if not specified (a remote overlay's `verbosity` wins over +the `-v` flag): - 0: Shows all ERROR, WARN and INFO - 1: Shows all of 0 plus DEBUG logs - 2: Shows all of 1 plus TRACE logs + +## Plugin `enabled` + +```yaml +plugins: + : + enabled: false # default true +``` + +A disabled plugin gets no schedule, no download and no run state, but it stays in the configuration the agent reports. + +## Typing of plugin values + +Values in the config file keep viper's weak typing exactly as before: `collect_ip_allow_list: false` reaches the plugin +as `"0"`, `account_id: 123456789012` as `"123456789012"`, and `port: 22` as `"22"`. Values set by a remote overlay +must already be strings: a remote `port: 2222` (a number) is rejected with `invalid-type` (R27, R51). + +Viper lowercases keys and splits them on dots. Plugin names and config keys in the file are therefore lowercase and +cannot contain dots; a remote overlay that uses `GitHub` addresses a different plugin than the file's `github`. Plugin +names an overlay introduces must match `^[a-z0-9][a-z0-9_-]{0,62}$` (R28). + +## `${env:NAME}` placeholders + +A `plugins.

.config` value may reference environment variables, whole or embedded: + +```yaml +plugins: + postgres: + config: + password: "${env:PG_PASSWORD}" + dsn: "postgres://app:${env:PG_PASSWORD}@db:5432/app" +``` + +Placeholders are resolved **only** in `plugins.*.config`, in the file and in a remote overlay. Anywhere else (for +example `policy_data` or `labels`) a placeholder is not resolved: in the file it is passed through as a literal string, +as it always was, and reported as a warning; a remote overlay that puts one there is rejected. `CCF_API_AUTH_*` may +never be referenced. An unset variable that the **file** references is a warning, and the value reaches the plugin +unchanged (the literal `${env:NAME}`), exactly as before placeholders were resolved (R60). An unset variable that a +remote overlay introduces fails the revision with `env-missing`; the error names the variable, never a value. Reports, +redaction and the configuration digest always use the unresolved placeholder, so rotating a secret never changes them +(R24). + +Plugin values set through viper environment variables (`CCF_PLUGINS_

_CONFIG_`, see the README) are masked as +`••••` in every report and in the configuration digest (R25). The rest of the redaction is the API's +`pkg/agentconfig` (`Redact`, `Digest`), which the agent uses as is and which is the source of truth. In short, it masks +values under secret-like keys (for example `password`, `token`, `secret`, `api_key`, `dsn`, `auth`) and secret-looking +values under any key (a password in a URL, a PEM private key, a `password=` assignment, known token formats). Literal +text mixed with a `${env:NAME}` placeholder under a secret-like key is masked too; a value made only of placeholders is +reported as written. + +## Tolerated file problems + +A plugin `schedule` in the file that does not parse does not stop the agent: that plugin is skipped, the others run, +and the problem is logged and reported as a warning (R34). A few other file values that always loaded are also only +warnings, and are kept unchanged: a negative `verbosity` (`-1` logs WARN and above), a literal `${env:...}` outside +`plugins.*.config`, and an unset variable referenced from the file's `plugins.*.config` (see above). Every other +invalid value in the file (for example a missing `api.url`) still fails startup, and on a live reload the agent keeps +running its last good configuration. Values set by a remote overlay are always validated strictly. + +## Remote configuration + +An agent with `api.auth` credentials can pick up a configuration overlay stored in the API. The `remote_config` block +controls it. It is **set locally only** (file, host environment, CLI flags), never remotely (R30): + +```yaml +remote_config: + mode: report # off | report | apply_safe | apply_all + poll_interval: 60s # at least 15s + trusted_sources: [] # glob list of plugin/policy sources an overlay may introduce + overridable_config_flags: [] # glob list of plugins.*.config keys an overlay may change + allow_local_sources: false +``` + +Defaults (R29): `mode` is `report` when `api.auth` is set and `off` otherwise (no credentials always forces +`off`), so an agent applies an overlay only when `mode` is set to `apply_safe` or `apply_all`; `poll_interval` is `60s`; `trusted_sources` and `overridable_config_flags` are empty; +`allow_local_sources` is `false`. `CCF_REMOTE_CONFIG_MODE` sets the mode even when the file has no +`remote_config` block. + +| Mode | Behaviour | +|---|---| +| `off` | No report, no fetch. The heartbeat carries no configuration fields. | +| `report` | The agent reports its configuration (status `not-applicable`) but never fetches an overlay. | +| `apply_safe` | The agent fetches the overlay and applies it only when every change is safe (table below). | +| `apply_all` | The agent applies safe and unsafe changes. Forbidden changes are still rejected. | + +A change is classified as follows (the agent is the authority; the API preview uses the same rules): + +| Change | Class | +|---|---| +| `api`, `daemon` or `remote_config` in the overlay | **forbidden** (the whole revision is rejected in every mode) | +| `verbosity`, `agent_evidence.*` | safe | +| a plugin's `schedule`, `labels`, `policy_behavior`, `protocol_version`, `enabled`, `policy_data` | safe | +| removing a plugin or a policy entry | safe | +| a plugin source or policy entry already used by the file | safe | +| a new source matching `trusted_sources` | safe | +| a new OCI source not in `trusted_sources` | unsafe | +| a new local path | forbidden, unless `apply_all` with `allow_local_sources: true` (then unsafe) | +| a `plugins.

.config.` change matching `overridable_config_flags` (`key`, `plugin:key` or `*`) | safe | +| any other plugin config change | unsafe | +| a new `${env:NAME}` reference | unsafe (`CCF_API_AUTH_*`: forbidden) | + +A rejected or failed revision never interrupts the running configuration: the agent prepares the whole new +configuration (validation, downloads) first and swaps only when it is ready. Every outcome is reported to the API with +a reason (`unsafe-changes`, `forbidden-changes`, `invalid-config`, `invalid-type`, `unknown-field`, `env-missing`, +`download-failed`, `cache-corrupt`, `internal`). When a new configuration is applied, +in-flight plugin runs get up to 5 minutes to finish (R33). Evidence produced under an overlay carries the prop +`agent-config-revision` (namespace `https://compliance-framework.github.io/ns`). + +The agent caches the last fetched and applied overlay in `/remote-config.json` (mode 0600, bound to `api.url` +and `api.auth.client_id`), so it keeps running the last good overlay when the API is unreachable. At startup it tries, +in order: the freshly fetched overlay, the cached applied overlay, the file alone. Only an unusable file stops the agent. +A fetched overlay already rejected for the same file is skipped, and its rejection (with the unsafe changes) is reported +again, so the instance still shows as rejected after a restart. + +When a configuration report is too large for the API, the agent drops its `base` document and marks it truncated; +the effective document and digest are kept. + +### Plugin library versions (R76) + +The report lists the instance's plugins as `plugins[]` (`name`, `source`, `lib-version`), where `lib-version` is +the version of this agent library the plugin binary was built with, read from its Go build info without starting it. +It is empty when unknown (a `replace`d or `(devel)` build, or a binary without build info). It is diagnostic only: +nothing is gated on it. + +## State directory and instance ID + +Each agent instance keeps state in `.compliance-framework/state//`, relative to the working directory, where +`` is derived from the absolute path of the config file (R31): the instance ID (`instance-id`) and the remote +configuration cache. The OCI download caches in `.compliance-framework/plugins` and +`.compliance-framework/policies` are shared. + +| Setting | Flag | Environment | +|---|---|---| +| State directory | `--state-dir` | `CCF_STATE_DIR` | +| Instance ID (a UUID; not persisted) | `--instance-id` | `CCF_INSTANCE_ID` | + +Because the default key depends on the config file's path, **moving or renaming the config file creates a new +instance** (R52). The agent logs the state directory, where it came from and the instance ID at startup. Containers and +Helm deployments should pin `CCF_STATE_DIR` to a mounted volume; ephemeral one-shot runs (CI, Kubernetes jobs) can +set `CCF_INSTANCE_ID` so repeated runs report as one instance. + +Plugins receive the agent's environment except `CCF_API_AUTH_*` (R26). diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 04677f6..6218352 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -74,7 +74,7 @@ Every evidence the agent sends also records where its plugin and policy bundle c | --- | --- | | `_plugin_source` | The plugin's configured `source`: an OCI reference such as `ghcr.io/compliance-framework/plugin-apt-versions:v0.4.0`, or a local path | | `_plugin_digest` | For an OCI source, the registry digest the reference resolved to when the agent downloaded it; for a local plugin binary, its SHA-256 | -| `_policy_source` | The configured source of the policy bundle the evaluation used (only when the evidence carries a `PolicyEvaluation`, so the bundle is known) | +| `_policy_source` | The configured source of the policy bundle the evaluation used (only when the evidence carries a `PolicyEvaluation`, or, from plugins built on an older agent library, a `_policy_path` label naming one of the plugin's policy paths, so the bundle is known) | | `_policy_digest` | For an OCI source, the registry digest the reference resolved to when the agent downloaded it. Not set for a local directory; `_policy_bundle_digest` covers its content | With `_plugin_source` and `_plugin_digest`, the image is pinned (`ref@digest`) even if the tag @@ -86,5 +86,8 @@ before digests were recorded have no such record: their evidence carries the sou digest until they are downloaded again (a new version, a cleared cache, or a fresh agent volume). +The agent looks the policy source up by the path it gave the plugin, which is the path the +plugin reports the evaluation under. + The agent owns these props: any a plugin sets itself are replaced. They are recorded whether or not the evaluation's artifacts could be stored. diff --git a/docs/running_as_a_service.md b/docs/running_as_a_service.md index 1dc7bfa..79055e5 100644 --- a/docs/running_as_a_service.md +++ b/docs/running_as_a_service.md @@ -84,7 +84,9 @@ WantedBy=multi-user.target [Service] Type=notify -ExecStart=/usr/local/bin/ccf-agent agent -d +WorkingDirectory=/var/lib/ccf-agent +StateDirectory=ccf-agent +ExecStart=/usr/local/bin/ccf-agent agent -d -c /etc/ccf-agent/config.yaml KillMode=process Delegate=yes LimitNOFILE=1048576 @@ -97,6 +99,12 @@ RestartSec=5s EOF ``` +`WorkingDirectory` and `StateDirectory` give the agent a persistent place for its download caches and its +per-instance state (`.compliance-framework/state/...`: the instance ID and the remote configuration cache). Without +them the agent writes relative to `/`. The state directory must persist across restarts, +otherwise every restart registers a new instance. See +[State directory and instance ID](configuration.md#state-directory-and-instance-id). + Now run the following command to reload the systemd configuration: ```bash @@ -135,7 +143,10 @@ TODO ## Running as a server/container -TODO +Mount a volume for the agent's state and pin it with `CCF_STATE_DIR`: the default state directory is derived from the +config file's absolute path, so a container that mounts its config elsewhere would otherwise get a new instance ID +(R52). In Kubernetes jobs and CI one-shot runs, set `CCF_INSTANCE_ID` to a fixed UUID so repeated runs report as one +instance; one-shot instances are pruned by the API after 24h. ## Running as a serverless process in AWS diff --git a/go.mod b/go.mod index 0461ee6..d81c387 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/compliance-framework/agent go 1.26.1 require ( - github.com/compliance-framework/api v0.20.0 + github.com/compliance-framework/api v0.20.1-0.20261002142938-b53be8f0c388 github.com/compliance-framework/gooci v0.0.6 github.com/coreos/go-systemd/v22 v22.7.0 github.com/defenseunicorns/go-oscal v0.7.0 @@ -59,7 +59,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/yamux v0.1.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.7 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/dsig v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect diff --git a/go.sum b/go.sum index db23ece..200848a 100644 --- a/go.sum +++ b/go.sum @@ -70,8 +70,8 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/compliance-framework/api v0.20.0 h1:/61+MLsNOkn2sX/kOzO2z8Q3dfk2dAmSzkx+RBLM118= -github.com/compliance-framework/api v0.20.0/go.mod h1:R0MBpd7m1rFeZIYB6kAm0u8o7vEjzMUGYIVeDRxr6Ao= +github.com/compliance-framework/api v0.20.1-0.20261002142938-b53be8f0c388 h1:RDbH4/dZrljjwnpLFDP64CSJ1t87QsW07OCTjd2f2fw= +github.com/compliance-framework/api v0.20.1-0.20261002142938-b53be8f0c388/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= github.com/compliance-framework/gooci v0.0.6 h1:61N3igJyGdSO5hYD4ODgQ6YhzixbK6mMCzsNLyigBQ4= github.com/compliance-framework/gooci v0.0.6/go.mod h1:vbiRPS2mbxW2VIKhpkOOK6uftKjv9l3fYOr3m+ufwZA= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -220,8 +220,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= +github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -268,12 +268,12 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= -github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= +github.com/moby/sys/user v0.4.1 h1:RgjRlaDKi/Xmyrz4t8lyzXT6v2ooFeO/7xtchmhVWE0= +github.com/moby/sys/user v0.4.1/go.mod h1:E9QsW5WRe1kUAf7kW8hXKwu1uhsZEAdPLYHYSDudF4Y= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= diff --git a/internal/agentstate/cache.go b/internal/agentstate/cache.go new file mode 100644 index 0000000..425a32f --- /dev/null +++ b/internal/agentstate/cache.go @@ -0,0 +1,143 @@ +package agentstate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/compliance-framework/api/pkg/agentconfig" +) + +const ( + cacheFile = "remote-config.json" + cacheVersion = 1 +) + +// ErrCacheCorrupt is returned by LoadCache when the cache file does not parse or its checksum +// does not match. The agent reports failed/cache-corrupt once and continues without the cache. +var ErrCacheCorrupt = errors.New("remote config cache corrupt") + +// Identity binds a cache to the API and credentials it was fetched with (R7). A cache whose +// identity differs from the current one is discarded, so a new client_id or API URL never +// applies another agent's overlay. +type Identity struct { + APIURL string `json:"api_url"` + ClientID string `json:"client_id"` +} + +// OverlayRecord is one overlay document received from the API. +type OverlayRecord struct { + Revision int64 `json:"revision"` + // ETag is the RAW ETag header of the 200 response (opaque, e.g. "r-"). It is + // sent back verbatim as If-None-Match and never built from a revision number (R7). + ETag string `json:"etag"` + Overlay json.RawMessage `json:"overlay"` + FetchedAt time.Time `json:"fetched_at"` +} + +// RejectedRecord remembers that a fetched overlay was rejected against a given base, so it +// is not re-prepared on every poll. It is keyed by (ETag, BaseFingerprint); the revision is +// informational only because a reset API can reuse revision numbers, except when the +// response carried no ETag: then (Revision, OverlaySHA256) stands in for it. +type RejectedRecord struct { + Revision int64 `json:"revision"` + ETag string `json:"etag"` + // OverlaySHA256 keys the record with the revision when the response had no ETag. + OverlaySHA256 string `json:"overlay_sha256,omitempty"` + BaseFingerprint string `json:"base_fingerprint"` + Status string `json:"status"` + Reason string `json:"reason"` + Error string `json:"error"` + // Unsafe completes the outcome re-reported after a restart. It is optional: caches + // written before it existed load (and checksum) unchanged. + Unsafe []agentconfig.Change `json:"unsafe,omitempty"` +} + +// Cache is the persisted remote configuration state (0600, it may hold values an admin typed). +type Cache struct { + Version int `json:"version"` + Identity Identity `json:"identity"` + Applied *OverlayRecord `json:"applied,omitempty"` + Fetched *OverlayRecord `json:"fetched,omitempty"` // newest 200 body; may be the rejected one + Rejected *RejectedRecord `json:"rejected,omitempty"` + Checksum string `json:"checksum"` // sha256 of the JSON with Checksum = "" +} + +// IfNoneMatch is the ETag to present on the next fetch: the last 200's raw ETag, falling back +// to the applied one, or "" for an unconditional fetch. +func (c *Cache) IfNoneMatch() string { + if c == nil { + return "" + } + if c.Fetched != nil && c.Fetched.ETag != "" { + return c.Fetched.ETag + } + if c.Applied != nil { + return c.Applied.ETag + } + return "" +} + +func (c Cache) checksum() (string, error) { + c.Checksum = "" + raw, err := json.Marshal(c) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +// CachePath returns the cache file path. +func (s *Store) CachePath() string { return filepath.Join(s.dir, cacheFile) } + +// LoadCache reads the cache for identity id. A missing file yields an empty cache. A corrupt +// file yields an empty cache and ErrCacheCorrupt. A cache bound to another identity is +// discarded (empty cache, nil error, one INFO). +func (s *Store) LoadCache(id Identity) (*Cache, error) { + empty := &Cache{Version: cacheVersion, Identity: id} + raw, err := os.ReadFile(s.CachePath()) + if errors.Is(err, os.ErrNotExist) { + return empty, nil + } + if err != nil { + return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err) + } + var c Cache + if err := json.Unmarshal(raw, &c); err != nil { + return empty, fmt.Errorf("%w: %v", ErrCacheCorrupt, err) + } + want, err := c.checksum() + if err != nil || c.Checksum != want || c.Version != cacheVersion { + return empty, fmt.Errorf("%w: checksum or version mismatch", ErrCacheCorrupt) + } + if c.Identity != id { + s.logger.Info("Discarding the remote config cache: it belongs to another API URL or client ID") + return empty, nil + } + return &c, nil +} + +// SaveCache writes the cache atomically (temp file, fsync, rename) with mode 0600. It is a +// no-op error when the store is not writable. +func (s *Store) SaveCache(c *Cache) error { + if !s.writable { + return errors.New("state directory is not writable") + } + c.Version = cacheVersion + sum, err := c.checksum() + if err != nil { + return err + } + c.Checksum = sum + raw, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return WriteFileAtomic(s.CachePath(), raw, 0o600) +} diff --git a/internal/agentstate/cache_test.go b/internal/agentstate/cache_test.go new file mode 100644 index 0000000..a5456ed --- /dev/null +++ b/internal/agentstate/cache_test.go @@ -0,0 +1,57 @@ +package agentstate + +import ( + "encoding/json" + "errors" + "os" + "testing" + "time" +) + +func TestCache_RoundTripAndIfNoneMatch(t *testing.T) { + s := Open(t.TempDir(), nil) + id := Identity{APIURL: "http://api.test", ClientID: "c"} + c, err := s.LoadCache(id) + if err != nil || c.IfNoneMatch() != "" { + t.Fatalf("empty cache: %+v %v", c, err) + } + c.Applied = &OverlayRecord{Revision: 1, ETag: `"r1-a"`, Overlay: json.RawMessage(`{"plugins": {}}`), FetchedAt: time.Now().UTC()} + if got := c.IfNoneMatch(); got != `"r1-a"` { + t.Fatalf("IfNoneMatch falls back to applied, got %q", got) + } + c.Fetched = &OverlayRecord{Revision: 2, ETag: `W/"r2-b"`, Overlay: json.RawMessage(`{}`)} + if got := c.IfNoneMatch(); got != `W/"r2-b"` { + t.Fatalf("IfNoneMatch prefers the raw fetched ETag, got %q", got) + } + if err := s.SaveCache(c); err != nil { + t.Fatal(err) + } + loaded, err := s.LoadCache(id) + if err != nil { + t.Fatalf("reload: %v", err) + } + if loaded.Fetched.ETag != `W/"r2-b"` || loaded.Applied.Revision != 1 { + t.Fatalf("round trip lost data: %+v", loaded) + } +} + +func TestCache_CorruptAndIdentity(t *testing.T) { + s := Open(t.TempDir(), nil) + id := Identity{APIURL: "http://api.test", ClientID: "c"} + c, _ := s.LoadCache(id) + c.Applied = &OverlayRecord{Revision: 1, ETag: `"r1-a"`, Overlay: json.RawMessage(`{}`)} + if err := s.SaveCache(c); err != nil { + t.Fatal(err) + } + other, err := s.LoadCache(Identity{APIURL: "http://api.test", ClientID: "d"}) + if err != nil || other.Applied != nil { + t.Fatalf("an identity mismatch must yield an empty cache: %+v %v", other, err) + } + if err := os.WriteFile(s.CachePath(), []byte(`{"version":1,"checksum":"nope"}`), 0o600); err != nil { + t.Fatal(err) + } + empty, err := s.LoadCache(id) + if !errors.Is(err, ErrCacheCorrupt) || empty == nil || empty.Applied != nil { + t.Fatalf("expected ErrCacheCorrupt with an empty cache, got %+v %v", empty, err) + } +} diff --git a/internal/agentstate/store.go b/internal/agentstate/store.go new file mode 100644 index 0000000..6388aa3 --- /dev/null +++ b/internal/agentstate/store.go @@ -0,0 +1,181 @@ +// Package agentstate owns the agent's per-instance state directory: the stable instance ID +// (R31) and the remote configuration cache (R7). +// +// Layout (the OCI download caches under .compliance-framework/{plugins,policies} are shared +// and unchanged): +// +// .compliance-framework/state// key = hex(sha256(abs config path))[:16]; dir 0700 +// instance-id 0644, UUID + "\n" +// remote-config.json 0600 +// +// The package is a leaf: it never imports cmd. +package agentstate + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/google/uuid" + "github.com/hashicorp/go-hclog" +) + +const ( + // StateRoot is the default parent of every per-config state directory, relative to the + // working directory like the download caches. + StateRoot = ".compliance-framework/state" + + instanceIDFile = "instance-id" +) + +// Store is one agent instance's state directory. A Store whose directory is not writable +// keeps working in memory: the agent never fails because it cannot persist state. +type Store struct { + dir string + logger hclog.Logger + writable bool + + mu sync.Mutex + id uuid.UUID + idSet bool + warned bool +} + +// DefaultDir returns the default state directory for a config file: StateRoot/, where key +// is the first 16 hex characters of sha256 of the absolute config path. Moving or renaming the +// config file therefore changes the directory and the instance ID (R52); containers should +// pin CCF_STATE_DIR. +func DefaultDir(configPath string) (string, error) { + abs, err := filepath.Abs(configPath) + if err != nil { + return "", err + } + sum := sha256.Sum256([]byte(abs)) + return filepath.Abs(filepath.Join(StateRoot, hex.EncodeToString(sum[:])[:16])) +} + +// Open prepares dir (MkdirAll 0700) and probes that it is writable. A failure is logged once +// as a WARN and the store continues in memory; it is never fatal. +func Open(dir string, logger hclog.Logger) *Store { + if logger == nil { + logger = hclog.NewNullLogger() + } + s := &Store{dir: dir, logger: logger.Named("state")} + if err := os.MkdirAll(dir, 0o700); err != nil { + s.logger.Warn("State directory is not usable; state will not persist across restarts", "dir", dir, "error", err) + return s + } + probe, err := os.CreateTemp(dir, ".probe-*") + if err != nil { + s.logger.Warn("State directory is not writable; state will not persist across restarts", "dir", dir, "error", err) + return s + } + name := probe.Name() + _ = probe.Close() + _ = os.Remove(name) + s.writable = true + return s +} + +// Dir returns the state directory. +func (s *Store) Dir() string { return s.dir } + +// Writable reports whether the directory accepted a probe write at Open. +func (s *Store) Writable() bool { return s.writable } + +// InstanceID returns this instance's stable ID and whether it is persisted: +// 1. a valid override (flag or CCF_INSTANCE_ID) wins and is not persisted; +// 2. otherwise the instance-id file; +// 3. otherwise a new ID, written atomically (a corrupt file is replaced); +// 4. if the store is not writable, the new ID lives in memory only (one WARN). +// +// The result is memoized: every call on one Store returns the same ID. +func (s *Store) InstanceID(override string) (uuid.UUID, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + if o := strings.TrimSpace(override); o != "" { + if id, err := uuid.Parse(o); err == nil { + return id, false + } + s.logger.Warn("Ignoring invalid instance ID override", "value", o) + } + + path := filepath.Join(s.dir, instanceIDFile) + if s.idSet { + return s.id, s.writable && fileExists(path) + } + + if raw, err := os.ReadFile(path); err == nil { + if id, err := uuid.Parse(strings.TrimSpace(string(raw))); err == nil { + s.id, s.idSet = id, true + return id, true + } + s.logger.Warn("Instance ID file is corrupt; replacing it", "path", path) + } + + id := uuid.New() + s.id, s.idSet = id, true + if !s.writable { + s.warnOnce("Instance ID is kept in memory only; a restart creates a new instance", "dir", s.dir) + return id, false + } + if err := WriteFileAtomic(path, []byte(id.String()+"\n"), 0o644); err != nil { + s.warnOnce("Could not persist the instance ID; a restart creates a new instance", "path", path, "error", err) + return id, false + } + return id, true +} + +func (s *Store) warnOnce(msg string, args ...any) { + if s.warned { + return + } + s.warned = true + s.logger.Warn(msg, args...) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// WriteFileAtomic writes data to a temp file in path's directory, fsyncs it and renames it +// over path, so readers see either the old or the new content. +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + cleanup := func() { _ = os.Remove(tmpName) } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + cleanup() + return err + } + if err := tmp.Chmod(perm); err != nil { + _ = tmp.Close() + cleanup() + return err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + cleanup() + return err + } + if err := tmp.Close(); err != nil { + cleanup() + return err + } + if err := os.Rename(tmpName, path); err != nil { + cleanup() + return fmt.Errorf("rename %s: %w", path, err) + } + return nil +} diff --git a/internal/agentstate/store_test.go b/internal/agentstate/store_test.go new file mode 100644 index 0000000..fac5427 --- /dev/null +++ b/internal/agentstate/store_test.go @@ -0,0 +1,112 @@ +package agentstate + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/hashicorp/go-hclog" +) + +func TestInstanceID_PersistedAndReused(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + first, persisted := Open(dir, nil).InstanceID("") + if !persisted { + t.Fatal("expected the ID to be persisted") + } + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o700 { + t.Fatalf("state dir mode = %v, want 0700", info.Mode().Perm()) + } + second, persisted := Open(dir, nil).InstanceID("") + if !persisted || second != first { + t.Fatalf("expected the persisted ID to be reused: %s vs %s", first, second) + } +} + +func TestInstanceID_OverrideNotPersisted(t *testing.T) { + dir := t.TempDir() + override := uuid.New() + id, persisted := Open(dir, nil).InstanceID(override.String()) + if id != override || persisted { + t.Fatalf("override: got %s persisted=%v", id, persisted) + } + if _, err := os.Stat(filepath.Join(dir, instanceIDFile)); !os.IsNotExist(err) { + t.Fatalf("override must not be written, stat err = %v", err) + } +} + +func TestInstanceID_ReadOnlyDirKeepsIDInMemory(t *testing.T) { + if runtime.GOOS == "windows" || os.Geteuid() == 0 { + t.Skip("permission bits are not enforced") + } + dir := t.TempDir() + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) + + var logs bytes.Buffer + logger := hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Warn}) + s := Open(dir, logger) + id, persisted := s.InstanceID("") + if id == uuid.Nil || persisted { + t.Fatalf("expected an in-memory ID, got %s persisted=%v", id, persisted) + } + if again, _ := s.InstanceID(""); again != id { + t.Fatalf("in-memory ID must be stable for the process: %s vs %s", id, again) + } + if !strings.Contains(logs.String(), "[WARN]") { + t.Fatalf("expected a WARN, got %q", logs.String()) + } +} + +func TestInstanceID_CorruptFileReplaced(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, instanceIDFile) + if err := os.WriteFile(path, []byte("not-a-uuid\n"), 0o644); err != nil { + t.Fatal(err) + } + id, persisted := Open(dir, nil).InstanceID("") + if !persisted { + t.Fatal("expected the replacement to be persisted") + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(raw)) != id.String() { + t.Fatalf("file holds %q, want %s", raw, id) + } +} + +func TestDefaultDir_DependsOnConfigPath(t *testing.T) { + a, err := DefaultDir("/etc/ccf/a.yaml") + if err != nil { + t.Fatal(err) + } + b, err := DefaultDir("/etc/ccf/b.yaml") + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatalf("two config paths must get two state dirs, got %s", a) + } + if !strings.Contains(filepath.ToSlash(a), StateRoot+"/") || len(filepath.Base(a)) != 16 { + t.Fatalf("unexpected layout %s", a) + } + + root := t.TempDir() + idA, _ := Open(filepath.Join(root, filepath.Base(a)), nil).InstanceID("") + idB, _ := Open(filepath.Join(root, filepath.Base(b)), nil).InstanceID("") + if idA == idB { + t.Fatal("two config paths must get two instance IDs") + } +} diff --git a/internal/oci.go b/internal/oci.go index 704dd5a..3f24f79 100644 --- a/internal/oci.go +++ b/internal/oci.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" + "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/gooci/pkg/oci" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" @@ -18,10 +19,11 @@ import ( "github.com/hashicorp/go-hclog" ) +// IsOCI reports whether source parses as an OCI tag with strict validation, which is what our +// downloader supports. It delegates to the shared agentconfig rule so the agent and the API +// classify sources identically (R3). func IsOCI(source string) bool { - // Check whether this can be parsed as an OCI tag, which is what our downloader supports. - _, err := name.NewTag(source, name.StrictValidation) - return err == nil + return agentconfig.IsOCISource(source) } func GetAnnotations(ctx context.Context, source string, option ...remote.Option) (map[string]string, error) { diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go new file mode 100644 index 0000000..6dac3d4 --- /dev/null +++ b/internal/pluginlib/pluginlib.go @@ -0,0 +1,81 @@ +// Package pluginlib reads which version of this module (the agent library) a plugin binary +// was built with (R76). The config report lists it per plugin as diagnostics. +// +// Plugins evaluate policies with the policy-manager they embed, so how a plugin evaluates a +// policy depends on the agent library it was compiled against, not on the running agent. +// The version comes from the binary's Go build info (debug/buildinfo), so the plugin is never +// started to find out. +package pluginlib + +import ( + "debug/buildinfo" + "os" + "sync" + "time" +) + +// AgentModule is the module path plugins import for runner and policy-manager. +const AgentModule = "github.com/compliance-framework/agent" + +// Version returns the version of AgentModule the plugin binary at path was built with, or "" +// when it is unknown: the file has no Go build info, does not depend on AgentModule (a +// non-Go or unrelated binary), or replaces it (a local build, where the version says nothing +// about the code). An error is returned only when the file cannot be read as a Go binary. +func Version(path string) (string, error) { + info, err := buildinfo.ReadFile(path) + if err != nil { + return "", err + } + for _, dep := range info.Deps { + if dep.Path != AgentModule { + continue + } + if dep.Replace != nil || dep.Version == "(devel)" { + return "", nil + } + return dep.Version, nil + } + return "", nil +} + +// Cache memoizes Version per binary. A binary is identified by its path, size and +// modification time, so a plugin replaced in place is read again. It is safe for concurrent +// use. +type Cache struct { + mu sync.Mutex + entries map[string]cacheEntry +} + +type cacheEntry struct { + size int64 + modTime time.Time + version string + err error +} + +// cacheLimit bounds the cache; plugins are few, so it is only a safety net. +const cacheLimit = 256 + +// Version is the package-level Version, memoized. +func (c *Cache) Version(path string) (string, error) { + st, err := os.Stat(path) + if err != nil { + return "", err + } + c.mu.Lock() + if e, ok := c.entries[path]; ok && e.size == st.Size() && e.modTime.Equal(st.ModTime()) { + c.mu.Unlock() + return e.version, e.err + } + c.mu.Unlock() + + version, err := Version(path) + + c.mu.Lock() + defer c.mu.Unlock() + if c.entries == nil || len(c.entries) >= cacheLimit { + c.entries = map[string]cacheEntry{} + } + c.entries[path] = cacheEntry{size: st.Size(), modTime: st.ModTime(), version: version, err: err} + return version, err +} diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go new file mode 100644 index 0000000..c2b1777 --- /dev/null +++ b/internal/pluginlib/pluginlib_test.go @@ -0,0 +1,33 @@ +package pluginlib + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVersion(t *testing.T) { + // The test binary is built from this module itself, so it does not depend on it. + self, err := os.Executable() + require.NoError(t, err) + version, err := Version(self) + require.NoError(t, err) + assert.Empty(t, version) + + notGo := filepath.Join(t.TempDir(), "plugin") + require.NoError(t, os.WriteFile(notGo, []byte("#!/bin/sh\necho hi\n"), 0o755)) + _, err = Version(notGo) + assert.Error(t, err) + + var cache Cache + version, err = cache.Version(self) + require.NoError(t, err) + assert.Empty(t, version) + _, err = cache.Version(notGo) + assert.Error(t, err) + _, err = cache.Version(filepath.Join(t.TempDir(), "missing")) + assert.Error(t, err) +} diff --git a/main.go b/main.go index 01807b6..f380186 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,12 @@ import ( "os" ) +// version is set by goreleaser's default ldflags (-X main.version=...). +var version = "dev" + func main() { + cmd.SetAgentVersion(version) + var rootCmd = &cobra.Command{ Use: "cf", Short: "cf manages policies for the compliance framework", diff --git a/policy-manager/evidence_seed_test.go b/policy-manager/evidence_seed_test.go new file mode 100644 index 0000000..625c4bc --- /dev/null +++ b/policy-manager/evidence_seed_test.go @@ -0,0 +1,47 @@ +package policy_manager + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEvidenceSeedIsUnchanged pins the evidence UUIDs plugins in the field produce for +// several label and path combinations. Every evidence stream depends on them: they must +// never change. +func TestEvidenceSeedIsUnchanged(t *testing.T) { + const ( + // vendorPath is the policy path an OCI bundle is passed as: relative to the agent's + // working directory, with the repository and tag. + vendorPath = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" + // localPath is a local policy source, passed as configured. + localPath = "/etc/ccf/policies/ssh" + ) + sshLabels := func(policyPath string) map[string]string { + return map[string]string{"type": "ssh", "hostname": "web-1", "_policy_path": policyPath} + } + cases := []struct { + name string + labels map[string]string + file, pkg string + want string + }{ + {"relative OCI path", sshLabels(vendorPath), vendorPath + "/ssh_deny_password_auth.rego", "data.compliance_framework.ssh_deny_password_auth", "cede5222-a458-4465-8134-c3751575cdd9"}, + {"absolute path, nested file", sshLabels(localPath), localPath + "/banner/banner.rego", "data.compliance_framework.banner", "0c0ee58a-50ca-45f1-98d3-0f9602f24101"}, + {"no _policy_path label", map[string]string{"_plugin": "test-plugin"}, "test.rego", "data.compliance_framework.no_policy_path", "271009cd-7758-432e-8869-84fa710b0f5a"}, + {"no labels", nil, "policies/a.rego", "data.compliance_framework.a", "9eb28e96-4f5a-416a-9623-62430b8e089f"}, + {"trailing slash", map[string]string{"type": "k8s", "_policy_path": "policies/", "cluster": "prod"}, "policies/a.rego", "data.compliance_framework.a", "98fc06e7-b1a4-4d83-a272-df4fdd75d06c"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := &PolicyProcessor{labels: tc.labels} + e, err := p.newEvidence(Result{ + Policy: Policy{File: tc.file, Package: Package(tc.pkg)}, + EvalOutput: &EvalOutput{Title: Pointer("t")}, + }, nil) + require.NoError(t, err) + assert.Equal(t, tc.want, e.UUID) + }) + } +} diff --git a/runner/result.go b/runner/result.go index 6055785..270ed7d 100644 --- a/runner/result.go +++ b/runner/result.go @@ -17,6 +17,8 @@ type apiHelper struct { agentLabels map[string]string pluginName string artifacts *artifactUploader + // evidenceProps are appended to every evidence the plugin creates. + evidenceProps []types.Property // pluginSource and policySources are where the plugin and its policy bundles came from, // recorded on evidence as _plugin_source / _plugin_digest and _policy_source / @@ -43,6 +45,10 @@ const ( PropPolicyDigest = "_policy_digest" ) +// LabelPolicyPath is the evidence label in which plugins record the policy path they were +// given (policy-manager's _policy_path). +const LabelPolicyPath = "_policy_path" + func isSourceProp(name string) bool { switch name { case PropPluginSource, PropPluginDigest, PropPolicySource, PropPolicyDigest: @@ -74,6 +80,15 @@ func WithPolicyPaths(paths []string) ApiHelperOption { } } +// WithEvidenceProps appends props to every evidence the plugin sends, unless the evidence +// already carries a prop with the same (ns, name). The agent uses it to stamp the applied +// remote configuration revision (R38). +func WithEvidenceProps(props ...types.Property) ApiHelperOption { + return func(h *apiHelper) { + h.evidenceProps = append(h.evidenceProps, props...) + } +} + func NewApiHelper(logger hclog.Logger, client *sdk.Client, agentLabels map[string]string, pluginName string, opts ...ApiHelperOption) *apiHelper { logger = logger.Named("api-helper") h := &apiHelper{ @@ -129,16 +144,14 @@ type apiEvidenceSender struct { } func (s *apiEvidenceSender) Send(e *proto.Evidence) { - var refs *types.PolicyArtifacts - policyPath := "" + var outcome evaluationOutcome if evaluation := e.GetPolicyEvaluation(); evaluation != nil { - outcome := s.outcome(evaluation) - refs, policyPath = outcome.refs, outcome.policyPath - if refs == nil { + outcome = s.outcome(evaluation) + if outcome.refs == nil { s.notReplayable++ } } - if err := s.h.client.Evidence.Create(s.ctx, s.h.toSdk(e, refs, policyPath)); err != nil { + if err := s.h.client.Evidence.Create(s.ctx, s.h.toSdk(e, outcome)); err != nil { s.sendErr = errors.Join(s.sendErr, err) } } @@ -181,10 +194,11 @@ func (s *apiEvidenceSender) Close() error { } // toSdk converts evidence for the API, merging agent, config and finding labels, and -// referring to its stored artifacts. The evaluation's raw data is not included. -func (h *apiHelper) toSdk(e *proto.Evidence, refs *types.PolicyArtifacts, policyPath string) types.Evidence { +// referring to its evaluation's stored artifacts (outcome is the zero value for evidence +// without an evaluation). The evaluation's raw data is not included. +func (h *apiHelper) toSdk(e *proto.Evidence, outcome evaluationOutcome) types.Evidence { evid := EvidenceProtoToSdk(e) - evid.PolicyArtifacts = refs + evid.PolicyArtifacts = outcome.refs // The agent owns the source props; any a plugin set are replaced. props := evid.Props[:0] for _, prop := range evid.Props { @@ -193,8 +207,17 @@ func (h *apiHelper) toSdk(e *proto.Evidence, refs *types.PolicyArtifacts, policy } } evid.Props = appendSource(props, h.pluginSource, PropPluginSource, PropPluginDigest) - if policyPath != "" { - evid.Props = appendSource(evid.Props, h.policySources[filepath.Clean(policyPath)], PropPolicySource, PropPolicyDigest) + if outcome.policyPath == "" { + // Plugins built on an agent library without policy evaluations still label their + // evidence with the policy path they were given. + if p := evid.Labels[LabelPolicyPath]; p != "" { + if _, known := h.policySources[filepath.Clean(p)]; known { + outcome.policyPath = p + } + } + } + if outcome.policyPath != "" { + evid.Props = appendSource(evid.Props, h.policySources[filepath.Clean(outcome.policyPath)], PropPolicySource, PropPolicyDigest) } labels := make(map[string]string) for k, v := range h.agentLabels { @@ -204,9 +227,27 @@ func (h *apiHelper) toSdk(e *proto.Evidence, refs *types.PolicyArtifacts, policy labels[k] = v } evid.Labels = labels + evid.Props = mergeProps(evid.Props, h.evidenceProps) return *evid } +// mergeProps appends each extra prop unless one with the same (ns, name) already exists. +func mergeProps(props []types.Property, extra []types.Property) []types.Property { + for _, p := range extra { + exists := false + for _, q := range props { + if q.Ns == p.Ns && q.Name == p.Name { + exists = true + break + } + } + if !exists { + props = append(props, p) + } + } + return props +} + func (h *apiHelper) UpsertRiskTemplates(ctx context.Context, packageName string, riskTemplates []*proto.RiskTemplate) error { templates := ProtoToSdk(riskTemplates, RiskTemplateProtoToSdk) diff --git a/runner/result_test.go b/runner/result_test.go index 7f8735a..d9bb9ac 100644 --- a/runner/result_test.go +++ b/runner/result_test.go @@ -99,3 +99,24 @@ func TestWithPluginSelectorLabelAppendsWhenMissing(t *testing.T) { t.Fatalf("expected plugin selector label to be appended, got %#v", got[1]) } } + +func TestMergePropsAppendsUnlessSameNsAndName(t *testing.T) { + existing := []types.Property{ + {Ns: "https://compliance-framework.github.io/ns", Name: "agent-config-revision", Value: "plugin-set"}, + {Name: "other", Value: "x"}, + } + extra := []types.Property{ + {Ns: "https://compliance-framework.github.io/ns", Name: "agent-config-revision", Value: "7"}, + {Ns: "https://example.test/ns", Name: "agent-config-revision", Value: "7"}, + } + got := mergeProps(existing, extra) + if len(got) != 3 { + t.Fatalf("expected 3 props, got %#v", got) + } + if got[0].Value != "plugin-set" { + t.Fatalf("an existing (ns, name) must win, got %#v", got[0]) + } + if got[2].Ns != "https://example.test/ns" { + t.Fatalf("a different namespace must be appended, got %#v", got[2]) + } +} diff --git a/runner/source_props_test.go b/runner/source_props_test.go index a0dc2f9..c22c6f0 100644 --- a/runner/source_props_test.go +++ b/runner/source_props_test.go @@ -150,3 +150,24 @@ func TestSourceWithoutDigestRecordsOnlyTheReference(t *testing.T) { assert.NotContains(t, props, PropPluginDigest) assert.NotContains(t, props, PropPolicyDigest) } + +// TestPolicyPathLabelRecordsThePolicySource: evidence without a policy evaluation (plugins +// built on an older agent library) records the source of the policy path it is labelled with, +// when that path is one the plugin was given. +func TestPolicyPathLabelRecordsThePolicySource(t *testing.T) { + bundle := writeBundle(t, "a") + api := &fakeAPI{} + helper := newTestHelper(t, api, bundle) + WithSources(testPlugin, map[string]Source{bundle: testPolicy})(helper) + + labelled := evidenceFor("labelled", nil) + labelled.Labels = map[string]string{LabelPolicyPath: bundle + "/"} + unknown := evidenceFor("unknown path", nil) + unknown.Labels = map[string]string{LabelPolicyPath: "/elsewhere/policies"} + require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{labelled, unknown})) + + props := sentProps(api) + assert.Equal(t, testPolicySource, props["labelled"][PropPolicySource]) + assert.Equal(t, testPolicyDigest, props["labelled"][PropPolicyDigest]) + assert.NotContains(t, props["unknown path"], PropPolicySource, "a path the plugin was not given records nothing") +}