From 5db3836ffedd7f2552f1f90fc560bb742c676540 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:06:25 -0300 Subject: [PATCH 01/47] test: record golden agent configuration hashes before the config refactor The G0 declared/runtime refactor must keep agentConfigurationHash byte-identical (it feeds the _agent label fallback and the agent evidence UUID). Record the values from the current loader first so the refactor commit can prove it. --- cmd/config_golden_test.go | 157 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 cmd/config_golden_test.go diff --git a/cmd/config_golden_test.go b/cmd/config_golden_test.go new file mode 100644 index 0000000..d738093 --- /dev/null +++ b/cmd/config_golden_test.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" +) + +// 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) + } + v := newGoldenViper(t, path) + config, err := loadConfig(AgentCmd(), v) + if err != nil { + t.Fatalf("load 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) + } + }) + } +} + +func newGoldenViper(t *testing.T, path string) *viper.Viper { + t.Helper() + v := viper.New() + v.SetConfigFile(path) + v.SetEnvPrefix("CCF") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + if err := bindAgentEnv(v); err != nil { + t.Fatalf("bind env: %v", err) + } + return v +} From 0d39af60ff3af16948edfd55fb510d70078dfa8c Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:11:32 -0300 Subject: [PATCH 02/47] refactor(config): adopt api/pkg/agentconfig as the declared config (G0) Pin compliance-framework/api to the in-flight-agent-config branch head (52e315b) for pkg/agentconfig and pkg/agentconfig/regocheck. The config now has two forms (R4): agentconfig.Config is the declared form that is decoded, validated and (later) merged, classified and reported; the existing private structs stay the runtime form, built by one toRuntime conversion so agentConfigurationHash stays byte-identical (golden test). - cmd/config.go: loadBase builds a fresh viper per load, decodes with viper's weak decoder exactly as before (R51), decodes policy_bundles outside viper (yaml/json/toml), records env-sourced plugin pointers (R25) and validates with the shared rules. - R34: file-origin problems that only logged before (a bad plugin schedule) become reported warnings and the plugin is skipped; every other validation error stays fatal. - R9: protocol_version 0 means auto; the explicit-0 file check stays. - plugins.*.enabled: disabled plugins are dropped from the runtime. - internal.IsOCI delegates to agentconfig.IsOCISource (R3). - Remove the unused queryBundles field and the OPA v0 rego import; fix the verbosity comment. --- cmd/agent.go | 219 ++++--------------- cmd/agent_test.go | 78 +++---- cmd/config.go | 444 ++++++++++++++++++++++++++++++++++++++ cmd/config_golden_test.go | 23 +- cmd/config_test.go | 317 +++++++++++++++++++++++++++ cmd/submit_evidence.go | 2 +- go.mod | 6 +- go.sum | 20 +- internal/oci.go | 8 +- 9 files changed, 861 insertions(+), 256 deletions(-) create mode 100644 cmd/config.go create mode 100644 cmd/config_test.go diff --git a/cmd/agent.go b/cmd/agent.go index abaf0de..b2ec777 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -13,7 +13,7 @@ import ( "os" "os/exec" "os/signal" - "path" + "path/filepath" "runtime" "sort" "strconv" @@ -28,6 +28,7 @@ import ( "github.com/compliance-framework/agent/internal" "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" @@ -37,7 +38,6 @@ import ( "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" @@ -78,50 +78,36 @@ 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. 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"` -} -// 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 + // inlinePolicyDirs maps "inline:" policy entries to their materialized directory. + inlinePolicyDirs map[string]string + // sync is what the heartbeat reports about the applied remote configuration (R11, R45). + sync syncMeta + // remote is the normalized remote_config block. + remote agentconfig.RemoteConfig } -func (ac *agentConfig) validate() error { - if err := ac.ApiConfig.validate(); err != nil { - return err - } - - if _, err := ac.agentEvidenceInterval(); err != nil { - return err - } - - 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) - } - } +// 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 +} - 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 +143,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 && @@ -248,91 +212,6 @@ with plugins to ensure continuous compliance.`, 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 - } - - 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"] - } -} - func updateAllPluginProtocols(agentConfig *agentConfig) { for _, pluginConfig := range agentConfig.Plugins { if pluginConfig != nil && !pluginConfig.protocolSet && pluginConfig.ProtocolVersion == 0 { @@ -412,45 +291,13 @@ 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 { + configPath, err := filepath.Abs(cmd.Flag("config").Value.String()) + if err != nil { return err } @@ -465,18 +312,28 @@ func agentRunner(cmd *cobra.Command, args []string) error { ctx, configCancel := context.WithCancel(context.Background()) defer configCancel() - v.OnConfigChange(func(in fsnotify.Event) { + watcher := viper.New() + watcher.SetConfigFile(configPath) + if err := watcher.ReadInConfig(); err != nil { + return err + } + watcher.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() + watcher.WatchConfig() // 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) + base, err := loadBase(cmd, configPath) + if err != nil { + logger.Error("Error loading new config", "error", err) + panic(err) + } + config, err := toRuntime(base.declared, nil, base.skip) if err != nil { logger.Error("Error loading new config", "error", err) panic(err) @@ -518,8 +375,6 @@ type AgentRunner struct { pluginRunMu sync.RWMutex pluginRuns map[string]pluginRunRecord firstAgentEvidenceSendStarted bool - - queryBundles []*rego.Rego } func NewAgentRunner() *AgentRunner { diff --git a/cmd/agent_test.go b/cmd/agent_test.go index 5d72fd6..5bb8072 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), "yaml") + 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, "yaml") 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, "yaml") 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, "yaml") 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, "yaml") 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, 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{ diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..ef8e631 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,444 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/pelletier/go-toml/v2" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "sigs.k8s.io/yaml" +) + +// 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; PolicyBundles decoded from raw bytes + 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 +} + +// 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. +// PolicyBundles are not decoded here (see decodePolicyBundles). +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) +} + +// decodePolicyBundles decodes the file's policy_bundles block WITHOUT viper (HLD §3.5.6): +// viper lowercases keys and splits them on dots, which would corrupt module paths such as +// "Policies/Max.Auth.rego". +func decodePolicyBundles(raw []byte, ext string) (map[string]*agentconfig.PolicyBundle, error) { + var jsonDoc []byte + switch ext { + case "yaml", "yml": + converted, err := yaml.YAMLToJSON(raw) + if err != nil { + return nil, fmt.Errorf("decode policy_bundles: %w", err) + } + jsonDoc = converted + case "json": + jsonDoc = raw + case "toml": + var doc map[string]any + if err := toml.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("decode policy_bundles: %w", err) + } + converted, err := json.Marshal(map[string]any{"policy_bundles": doc["policy_bundles"]}) + if err != nil { + return nil, fmt.Errorf("decode policy_bundles: %w", err) + } + jsonDoc = converted + default: + return nil, nil + } + + if len(bytes.TrimSpace(jsonDoc)) == 0 || bytes.Equal(bytes.TrimSpace(jsonDoc), []byte("null")) { + return nil, nil + } + var doc struct { + PolicyBundles map[string]*agentconfig.PolicyBundle `json:"policy_bundles"` + } + dec := json.NewDecoder(bytes.NewReader(jsonDoc)) + dec.UseNumber() + if err := dec.Decode(&doc); err != nil { + return nil, fmt.Errorf("decode policy_bundles: %w", err) + } + return doc.PolicyBundles, nil +} + +// 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, configExt(configPath)) +} + +// baseFromViper finishes loadBase on a viper that has read raw. +func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte, ext string) (*baseSnapshot, error) { + declared, err := declaredFromViper(cmd, v) + if err != nil { + return nil, err + } + bundles, err := decodePolicyBundles(raw, ext) + if err != nil { + return nil, err + } + if bundles == nil && v.IsSet("policy_bundles") { + return nil, fmt.Errorf("policy_bundles is only supported in yaml, json and toml config files") + } + declared.PolicyBundles = bundles + + 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: reported + skip map[string]string // plugin name -> reason, for tolerated 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), 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 + } + 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 +} + +// 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, inlineDirs map[string]string, skip map[string]string) (*agentConfig, error) { + out := &agentConfig{ + Daemon: c.Daemon, + Verbosity: c.Verbosity, + Plugins: map[string]*agentPlugin{}, + inlinePolicyDirs: inlineDirs, + 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 index d738093..9232810 100644 --- a/cmd/config_golden_test.go +++ b/cmd/config_golden_test.go @@ -3,10 +3,7 @@ package cmd import ( "os" "path/filepath" - "strings" "testing" - - "github.com/spf13/viper" ) // goldenHashFixtures are config files whose agentConfigurationHash was recorded BEFORE the @@ -121,11 +118,14 @@ func loadGoldenFixture(t *testing.T, yaml string) *agentConfig { if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { t.Fatalf("write fixture: %v", err) } - v := newGoldenViper(t, path) - config, err := loadConfig(AgentCmd(), v) + base, err := loadBase(AgentCmd(), path) if err != nil { t.Fatalf("load fixture: %v", err) } + config, err := toRuntime(base.declared, nil, base.skip) + if err != nil { + t.Fatalf("runtime fixture: %v", err) + } return config } @@ -142,16 +142,3 @@ func TestAgentConfigurationHashGolden(t *testing.T) { }) } } - -func newGoldenViper(t *testing.T, path string) *viper.Viper { - t.Helper() - v := viper.New() - v.SetConfigFile(path) - v.SetEnvPrefix("CCF") - v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) - v.AutomaticEnv() - if err := bindAgentEnv(v); err != nil { - t.Fatalf("bind env: %v", err) - } - return v -} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..1b000c1 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,317 @@ +package cmd + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/compliance-framework/api/pkg/agentconfig" + "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 +} + +func TestDecodePolicyBundles_PreservesModuleKeys(t *testing.T) { + tests := []struct { + ext string + content string + }{ + { + ext: "yaml", + content: ` +api: + url: http://localhost:8080 +policy_bundles: + ssh: + modules: + Policies/Max.Auth.rego: | + package compliance_framework.max_auth + a.b/c.rego: "package compliance_framework.c" +`, + }, + { + ext: "json", + content: `{ + "api": {"url": "http://localhost:8080"}, + "policy_bundles": {"ssh": {"modules": { + "Policies/Max.Auth.rego": "package compliance_framework.max_auth\n", + "a.b/c.rego": "package compliance_framework.c" + }}} +}`, + }, + { + ext: "toml", + content: ` +[api] +url = "http://localhost:8080" + +[policy_bundles.ssh.modules] +"Policies/Max.Auth.rego" = """package compliance_framework.max_auth +""" +"a.b/c.rego" = "package compliance_framework.c" +`, + }, + } + for _, tt := range tests { + t.Run(tt.ext, func(t *testing.T) { + base := mustLoadBase(t, tt.ext, tt.content) + bundle := base.declared.PolicyBundles["ssh"] + if bundle == nil { + t.Fatalf("expected ssh bundle, got %#v", base.declared.PolicyBundles) + } + if got := bundle.Modules["Policies/Max.Auth.rego"]; got != "package compliance_framework.max_auth\n" { + t.Fatalf("mixed-case module lost or altered: %q (modules %v)", got, bundle.Modules) + } + if got := bundle.Modules["a.b/c.rego"]; got != "package compliance_framework.c" { + t.Fatalf("dotted module lost or altered: %q (modules %v)", got, bundle.Modules) + } + }) + } +} + +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, nil, 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, 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, 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, nil, 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") + } +} + +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, 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, 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) + } +} + +func TestLoadBase_PolicyBundlesUnsupportedFormat(t *testing.T) { + _, err := loadBase(AgentCmd(), writeConfigFile(t, "env", "API.URL=http://localhost:8080\nPOLICY_BUNDLES=x\n")) + if err == nil || !strings.Contains(err.Error(), "policy_bundles is only supported") { + t.Fatalf("expected unsupported-format error, got %v", err) + } +} 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/go.mod b/go.mod index d0c2bd5..d27b1d9 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-rc2 + github.com/compliance-framework/api v0.19.1-0.20260930145709-52e315b5ca04 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 @@ -14,6 +14,7 @@ require ( github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/open-policy-agent/opa v1.14.1 + github.com/pelletier/go-toml/v2 v2.3.1 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -59,7 +60,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 @@ -74,7 +75,6 @@ require ( github.com/oklog/run v1.2.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect diff --git a/go.sum b/go.sum index e298ade..2d28179 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-rc2 h1:TULpI4xzpb9YQiSmdXczPi4WgpGDXO/PI/a0vrPix88= -github.com/compliance-framework/api v0.20.0-rc2/go.mod h1:R0MBpd7m1rFeZIYB6kAm0u8o7vEjzMUGYIVeDRxr6Ao= +github.com/compliance-framework/api v0.19.1-0.20260930145709-52e315b5ca04 h1:P5zwVj0+CYnGueMPc8KspTDJeCumZtfguFU92Rc5faI= +github.com/compliance-framework/api v0.19.1-0.20260930145709-52e315b5ca04/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/oci.go b/internal/oci.go index 6b5b5f4..da2f154 100644 --- a/internal/oci.go +++ b/internal/oci.go @@ -8,6 +8,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" @@ -15,10 +16,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) { From 16d6c6bf90a2c17e861e8e0f9400f8b9cd3356e6 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:16:55 -0300 Subject: [PATCH 03/47] feat(agent): stable instance ID, state dir and prepare-then-cancel reloads (G1) - internal/agentstate: per-config state dir (.compliance-framework/state/, 0700) with a persisted instance ID; --state-dir/CCF_STATE_DIR and --instance-id/CCF_INSTANCE_ID override it (R31). An unwritable dir keeps the ID in memory with one WARN. The resolved dir, its source and the ID are logged at startup (R52). - The heartbeat uses the stable ID instead of a fresh UUID per run. - cmd/reconciler.go: one serialized reconciler builds a complete, prefetched candidate before cancelling the running config (R32). An invalid file or a download failure no longer panics or exits; the running config keeps going. A run that fails on its own after a reload falls back to the previous config. Each load uses a fresh viper; the watcher only signals. - AgentRunner: Run/runDaemon return errors instead of os.Exit; Prefetch downloads without touching the running locations; a per-source protocol cache survives reloads; UpdateConfig reuses the SDK client when the api block is unchanged; reloads let in-flight runs drain for up to 5 minutes (R33). - Plugins no longer inherit CCF_API_AUTH_* (SkipHostEnv plus a filtered host environment, R26). - Drop the no-op global viper.BindPFlag calls; run tests with -race. --- Makefile | 2 +- cmd/agent.go | 296 +++++++++++++++++------ cmd/reconciler.go | 290 +++++++++++++++++++++++ cmd/reconciler_test.go | 378 ++++++++++++++++++++++++++++++ internal/agentstate/store.go | 182 ++++++++++++++ internal/agentstate/store_test.go | 112 +++++++++ 6 files changed, 1190 insertions(+), 70 deletions(-) create mode 100644 cmd/reconciler.go create mode 100644 cmd/reconciler_test.go create mode 100644 internal/agentstate/store.go create mode 100644 internal/agentstate/store_test.go 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/cmd/agent.go b/cmd/agent.go index b2ec777..65603a7 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -14,6 +14,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "reflect" "runtime" "sort" "strconv" @@ -27,19 +28,18 @@ import ( "github.com/robfig/cron/v3" "github.com/compliance-framework/agent/internal" + "github.com/compliance-framework/agent/internal/agentstate" "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/spf13/cobra" - "github.com/spf13/viper" "golang.org/x/sync/singleflight" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -166,6 +166,11 @@ const DefaultProtocolVersion int32 = 1 const RunnerV2ProtocolVersion int32 = 2 const AnnotationProtocolVersionKey = "org.ccf.plugin.protocol.version" const 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 @@ -201,14 +206,14 @@ 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") + agentCmd.Flags().String("state-dir", "", "Directory for this instance's state (instance ID, remote config cache, inline policies); overrides CCF_STATE_DIR. Default: .compliance-framework/state/") + agentCmd.Flags().String("instance-id", "", "Pin this instance's UUID (not persisted); overrides CCF_INSTANCE_ID") + return agentCmd } @@ -296,64 +301,84 @@ func configureRunner(name string, runnerInstance runner.RunnerV2, config agentPl // 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, err := filepath.Abs(cmd.Flag("config").Value.String()) - if 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() + configPath, err := filepath.Abs(cmd.Flag("config").Value.String()) + if err != nil { + return err + } - watcher := viper.New() - watcher.SetConfigFile(configPath) - if err := watcher.ReadInConfig(); err != nil { + stateDir, stateDirSource, err := stateDirFrom(cmd, configPath) + if err != nil { return err } - watcher.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() - }) - watcher.WatchConfig() + 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) - // 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()) - base, err := loadBase(cmd, configPath) - if err != nil { - logger.Error("Error loading new config", "error", err) - panic(err) - } - config, err := toRuntime(base.declared, nil, base.skip) - if err != nil { - logger.Error("Error loading new config", "error", err) - panic(err) - } - agentRun.UpdateConfig(config) - err = agentRun.Run(ctx) + ar := NewAgentRunner(WithInstanceID(id)) + rc := newReconciler(cmd, configPath, store, ar, logger) - 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 { @@ -371,32 +396,64 @@ 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 pluginRunMu sync.RWMutex pluginRuns map[string]pluginRunRecord firstAgentEvidenceSendStarted bool + + // 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 +} + +// 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() *AgentRunner { - return &AgentRunner{ +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 @@ -910,26 +967,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() @@ -952,12 +1026,17 @@ 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) @@ -966,18 +1045,18 @@ func (ar *AgentRunner) runDaemon(ctx context.Context) { 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 @@ -1006,18 +1085,19 @@ func (ar *AgentRunner) runDaemon(ctx context.Context) { ar.closePluginClients() logger.Debug("Exiting") os.Exit(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) + if !waitForCronStop(reloadDrainTimeout, agentCronStopCtx, heartbeatCronStopCtx, agentEvidenceCronStopCtx) { + logger.Warn("Timed out waiting for in-flight plugin runs to drain before reload", "timeout", reloadDrainTimeout) } logger.Debug("Shutting down plugins") ar.closePluginClients() - return + return nil } } @@ -1095,9 +1175,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()) } @@ -1150,6 +1234,9 @@ func (ar *AgentRunner) setupCron(ctx context.Context) (*cron.Cron, error) { 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 @@ -1164,14 +1251,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) } @@ -1685,10 +1772,15 @@ func safePluginErrorFilename(pluginName string) string { 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 := exec.Command(path) + // Plugins get the host environment minus the agent's own API credentials (R26); go-plugin + // would otherwise append the whole environment. + cmd.Env = pluginEnviron(os.Environ()) 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}, }) @@ -1788,6 +1880,72 @@ 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 (non-inline) 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() + } + platform := v1.Platform{ + Architecture: runtime.GOARCH, + OS: runtime.GOOS, + } + pluginSources := map[string]struct{}{} + policySources := map[string]struct{}{} + for _, pluginConfig := range cfg.Plugins { + pluginSources[pluginConfig.Source] = struct{}{} + for _, policy := range pluginConfig.Policies { + if _, inline := cfg.inlinePolicyDirs[string(policy)]; inline { + continue + } + policySources[string(policy)] = struct{}{} + } + } + for _, source := range sortedSetKeys(pluginSources) { + if _, err := ar.download(ctx, source, AgentPluginDir, "plugin", platformDownloadKey(platform), logger, remote.WithPlatform(platform)); err != nil { + return fmt.Errorf("download plugin %s: %w", source, err) + } + } + ar.resolveProtocolsFor(ctx, cfg, logger) + for _, source := range sortedSetKeys(policySources) { + if _, err := ar.downloadPolicy(ctx, source, logger); err != nil { + return fmt.Errorf("download policy %s: %w", source, err) + } + } + return nil +} + +// downloadPolicy fetches one policy source into the shared policy cache. +func (ar *AgentRunner) downloadPolicy(ctx context.Context, source string, logger hclog.Logger) (string, error) { + return ar.download(ctx, source, AgentPolicyDir, "policies", "", logger) +} + +func sortedSetKeys(set map[string]struct{}) []string { + keys := make([]string, 0, len(set)) + for k := range set { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func platformDownloadKey(platform v1.Platform) string { return strings.Join([]string{platform.OS, platform.Architecture, platform.Variant}, "/") } diff --git a/cmd/reconciler.go b/cmd/reconciler.go new file mode 100644 index 0000000..d5cfe6d --- /dev/null +++ b/cmd/reconciler.go @@ -0,0 +1,290 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/compliance-framework/agent/internal/agentstate" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/fsnotify/fsnotify" + "github.com/hashicorp/go-hclog" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// fileDebounce coalesces bursts of config file events (editors write in several steps). +var fileDebounce = 500 * time.Millisecond + +// 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 + 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 +} + +// 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 + PolicyErrors []agentconfig.PolicyError +} + +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 } + +// prefetcher is the part of AgentRunner the reconciler drives (a test seam). +type prefetcher interface { + Prefetch(ctx context.Context, cfg *agentConfig) error +} + +// runFunc runs one configuration until it is cancelled (daemon) or completes (one-shot). +type runFunc func(ctx context.Context, cfg *agentConfig) error + +// reconciler is the single writer of the configuration state. File (and, from G3, 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 + fileEvents chan struct{} + + mu sync.Mutex // guards active, pending, cancelRun + active *candidate + pending *candidate + cancelRun context.CancelFunc + + base *baseSnapshot // reconciler goroutine only +} + +func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Store, runner prefetcher, logger hclog.Logger) *reconciler { + if logger == nil { + logger = hclog.NewNullLogger() + } + return &reconciler{ + cmd: cmd, + configPath: configPath, + store: store, + runner: runner, + logger: logger.Named("reconciler"), + fileEvents: make(chan struct{}, 1), + } +} + +// startup loads the file and prepares the first candidate. An error means the local +// configuration is unusable: the agent exits 1, as it always has (no panic). +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.base = base + rc.logWarnings(base.warnings) + cand, aerr := rc.prepare(ctx, base) + if aerr != nil { + return nil, aerr + } + return cand, nil +} + +// prepare builds a candidate from a base. It never touches the running configuration. +func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot) (*candidate, *applyError) { + declared := base.declared + runtime, err := toRuntime(declared, nil, base.skip) + if err != nil { + return nil, &applyError{Status: agentconfig.StatusFailed, Reason: agentconfig.ReasonInvalidConfig, Err: err} + } + if err := rc.runner.Prefetch(ctx, runtime); err != nil { + return nil, &applyError{Status: agentconfig.StatusFailed, Reason: agentconfig.ReasonDownloadFailed, Err: err} + } + digest := agentconfig.Digest(declared, base.redactOpts()...) + runtime.sync = syncMeta{Digest: digest, Mode: runtime.remote.Mode} + return &candidate{ + base: base, + declared: declared, + runtime: runtime, + digest: digest, + identity: candidateIdentity(declared), + warnings: base.warnings, + }, nil +} + +func candidateIdentity(c agentconfig.Config) string { + raw, err := agentconfig.CanonicalJSON(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() + } +} + +// 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 *candidate + for { + runCtx, cancel := context.WithCancel(context.Background()) + rc.bind(active, cancel) + runErr := run(runCtx, active.runtime) + reload := runCtx.Err() != nil + cancel() + if runErr != nil && !reload { + if previous != nil { + rc.logger.Error("Configuration failed to run; falling back to the previous configuration", "error", runErr) + rc.onRunFailed(active, runErr) + active, previous = previous, nil + continue + } + return runErr + } + if !active.runtime.Daemon { + return runErr + } + next := rc.takePending() + if next == nil { + continue + } + previous, active = active, next + } +} + +// onRunFailed is called when a configuration that passed prepare fails to run on its own. +func (rc *reconciler) onRunFailed(_ *candidate, _ error) {} + +// loop is the daemon's reconcile goroutine. It returns when ctx is done. +func (rc *reconciler) loop(ctx context.Context) { + var debounce <-chan time.Time + for { + select { + case <-ctx.Done(): + return + case <-rc.fileEvents: + if debounce == nil { + debounce = time.After(fileDebounce) + } + case <-debounce: + debounce = nil + rc.reconcileFile(ctx) + } + } +} + +// reconcileFile handles a (debounced) config file change. +func (rc *reconciler) reconcileFile(ctx context.Context) { + base, err := loadBase(rc.cmd, rc.configPath) + if err != nil { + rc.logger.Error("Config file is invalid; keeping the running configuration", "error", err) + return + } + rc.base = base + rc.logWarnings(base.warnings) + cand, aerr := rc.prepare(ctx, base) + if aerr != nil { + rc.logger.Error("Could not prepare the new configuration; keeping the running configuration", "status", aerr.Status, "reason", aerr.Reason, "error", aerr.Err) + return + } + if cur := rc.current(); cur != nil && cur.identity == cand.identity { + rc.logger.Debug("Config file changed without changing the effective configuration") + return + } + rc.logger.Info("Applying the new configuration") + rc.swap(cand) +} + +func (rc *reconciler) logWarnings(warnings []agentconfig.FieldError) { + for _, w := range warnings { + rc.logger.Warn("Ignoring a problem in the config file; the plugin is skipped", "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..17a0736 --- /dev/null +++ b/cmd/reconciler_test.go @@ -0,0 +1,378 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "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. +type fakePrefetcher struct { + mu sync.Mutex + calls int + err error +} + +func (f *fakePrefetcher) Prefetch(_ context.Context, _ *agentConfig) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + return f.err +} + +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.reconcileFile(context.Background()) + 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.reconcileFile(context.Background()) + 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.reconcileFile(context.Background()) + 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.reconcileFile(context.Background()) + 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.reconcileFile(context.Background()) + 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) { + old := fileDebounce + fileDebounce = 5 * time.Millisecond + t.Cleanup(func() { fileDebounce = old }) + + rc, _, path := newTestReconciler(t, configWithSchedule("* * * * *")) + rec := newRunRecorder() + startReconciler(t, rc, rec) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go rc.loop(ctx) + + var writes atomic.Int32 + for i := 0; i < 50; i++ { + schedule := "*/5 * * * *" + if i%2 == 0 { + schedule = "*/7 * * * *" + } + if err := os.WriteFile(path, []byte(configWithSchedule(schedule)), 0o600); err != nil { + t.Fatal(err) + } + writes.Add(1) + rc.signalFile() + time.Sleep(time.Millisecond) + } + // The last write wins. + deadline := time.After(5 * time.Second) + for { + select { + case cfg := <-rec.started: + if *cfg.Plugins["ssh"].Schedule == "*/5 * * * *" { + return + } + case <-deadline: + t.Fatal("the last file write was never applied") + } + } +} + +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") + } +} diff --git a/internal/agentstate/store.go b/internal/agentstate/store.go new file mode 100644 index 0000000..b658389 --- /dev/null +++ b/internal/agentstate/store.go @@ -0,0 +1,182 @@ +// Package agentstate owns the agent's per-instance state directory: the stable instance ID +// (R31), the remote configuration cache (R7) and the materialized inline policy bundles. +// +// 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 +// inline/// materialized inline policy bundles +// +// 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") + } +} From f5bdf4d6556afc25c606e22a5e376cc38c1b4db9 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:27:16 -0300 Subject: [PATCH 04/47] feat(agent): report config and pull/apply the remote overlay (G2+G3) G2 and G3 ship together (R35). Reporting (G2): - remote_config mode comes from the file (normalized: apply_safe with auth, off without; CCF_REMOTE_CONFIG_MODE is bound, R30). - cmd/report.go builds the config report: redacted UNRESOLVED base and effective with the same env-sourced masked pointers the digest uses (R24, R25, R55), daemon, attempted/applied revision, status/reason, unsafe changes, R34 warnings, remote-config (snake_case), truncation to 3.5 MiB. Sent at startup, when it changes, after a send error and every 24h. 404/401/403 back off 10 min, 409 pauses reports for 1h, 413 resends truncated (R8, R36). - The heartbeat carries config_revision (0 = file only) and config_digest whenever the mode is not off (R11, R45). - main sets the reported agent version (goreleaser main.version). Pull and apply (G3): - internal/agentstate cache (0600, checksummed, bound to api.url + client_id) keeps the fetched, applied and rejected revisions. The ETag is opaque and sent back verbatim (R7). - prepare: ValidateOverlay (the only strict decode, R27/R51) with FieldError codes mapped to reasons (R43), then the Classify gate (forbidden rejects in every mode, R23), Merge, Validate with the R34 origin partition, ResolveEnv on plugins.*.config only (R24), then Prefetch. Nothing touches the network before Classify passes. - A rejected revision is remembered per (ETag, base fingerprint); a failed one is retried after 1m doubling to 10m. - Startup ladder: fetched, then applied, then file only (R32); one-shot fetches, applies, reports and runs once (R37). - Evidence carries agent-config-revision when an overlay is applied, through a new runner.WithEvidenceProps option (R38). --- cmd/agent.go | 43 +- cmd/reconciler.go | 714 +++++++++++++++++++++++-- cmd/reconciler_test.go | 15 +- cmd/remote_test.go | 840 ++++++++++++++++++++++++++++++ cmd/report.go | 226 ++++++++ internal/agentstate/cache.go | 135 +++++ internal/agentstate/cache_test.go | 57 ++ main.go | 5 + runner/result.go | 29 ++ runner/result_test.go | 21 + 10 files changed, 2019 insertions(+), 66 deletions(-) create mode 100644 cmd/remote_test.go create mode 100644 cmd/report.go create mode 100644 internal/agentstate/cache.go create mode 100644 internal/agentstate/cache_test.go diff --git a/cmd/agent.go b/cmd/agent.go index 65603a7..e6d257c 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -165,6 +165,12 @@ const AgentPolicyDir = ".compliance-framework/policies" const DefaultProtocolVersion int32 = 1 const RunnerV2ProtocolVersion int32 = 2 const AnnotationProtocolVersionKey = "org.ccf.plugin.protocol.version" + +// 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" const daemonCronStopTimeout = 30 * time.Second // reloadDrainTimeout bounds how long in-flight plugin runs may finish when a new @@ -328,6 +334,7 @@ func agentRunner(cmd *cobra.Command, args []string) error { ar := NewAgentRunner(WithInstanceID(id)) rc := newReconciler(cmd, configPath, store, ar, logger) + rc.instanceID = id active, err := rc.startup(context.Background()) if err != nil { @@ -1360,7 +1367,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { "auth_enabled", hasAPIAuth(config), "client_id", apiClientID(config), ) - resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths)) + resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths), runner.WithEvidenceProps(configRevisionProps(config)...)) policyBehaviorProto := policyBehaviorToProto(pluginConfig.PolicyBehavior) if err := initRunner(pluginName, pluginConfig.ProtocolVersion, runnerInstance, policyPaths, policyBehaviorProto, resultsHelper); err != nil { @@ -1491,7 +1498,7 @@ func (ar *AgentRunner) runPlugin(ctx context.Context, name string, plugin *agent "auth_enabled", hasAPIAuth(config), "client_id", apiClientID(config), ) - resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths)) + resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), runner.WithEvidenceProps(configRevisionProps(config)...)) policyBehaviorProto := policyBehaviorToProto(plugin.PolicyBehavior) if err := initRunner(name, plugin.ProtocolVersion, runnerInstance, policyPaths, policyBehaviorProto, resultsHelper); err != nil { @@ -1523,10 +1530,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 @@ -1535,6 +1539,32 @@ 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 { + if config == nil || config.sync.AppliedRevision <= 0 { + return nil + } + return []sdktypes.Property{{ + Ns: CCFPropNamespace, + Name: configRevisionPropName, + Value: strconv.FormatInt(config.sync.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 config != nil && config.sync.Mode != "" && config.sync.Mode != agentconfig.ModeOff { + rev := config.sync.AppliedRevision + hb.ConfigRevision = &rev + hb.ConfigDigest = config.sync.Digest + } + return hb +} + type agentEvidenceCreateRequest struct { sdktypes.Evidence BackMatter *oscalTypes_1_1_3.BackMatter `json:"back-matter,omitempty"` @@ -1643,6 +1673,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, diff --git a/cmd/reconciler.go b/cmd/reconciler.go index d5cfe6d..ff2b1eb 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -1,37 +1,69 @@ package cmd import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" + "math/rand" + "os" + "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" ) -// fileDebounce coalesces bursts of config file events (editors write in several steps). -var fileDebounce = 500 * time.Millisecond +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 +) // 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 - 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) + overlay *agentstate.OverlayRecord // nil = file only + declared agentconfig.Config // merged, ${env:} NOT resolved: reported and digested + runtime *agentConfig // resolved, enabled-only, skipped plugins removed, inline dirs set + 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 + identity string + bundles []agentconfig.PolicyBundleReport + warnings []agentconfig.FieldError // R34 file-origin warnings + policyWarnings []agentconfig.PolicyError // G3b severity=warning +} + +// 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 @@ -56,31 +88,116 @@ func (e *applyError) Error() string { 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 two 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 -// reconciler is the single writer of the configuration state. File (and, from G3, 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 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 + // 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) + now func() time.Time mu sync.Mutex // guards active, pending, cancelRun active *candidate pending *candidate cancelRun context.CancelFunc - base *baseSnapshot // reconciler goroutine only + // 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 + + failedETag string + 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 { @@ -94,49 +211,388 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor runner: runner, logger: logger.Named("reconciler"), fileEvents: make(chan struct{}, 1), + runFailed: make(chan *candidate, 1), + debounce: 500 * time.Millisecond, + newRemote: newSDKRemote, + lookupEnv: os.LookupEnv, + now: time.Now, + loggedOnce: map[string]bool{}, } } -// startup loads the file and prepares the first candidate. An error means the local -// configuration is unusable: the agent exits 1, as it always has (no panic). +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.base = base - rc.logWarnings(base.warnings) - cand, aerr := rc.prepare(ctx, base) - if aerr != nil { - return nil, aerr + 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 { + 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 outcome != nil { + rc.lastOutcome = outcome + } + rc.maybeReport(ctx, active, rc.lastOutcome) + rc.afterStartup(active) + return active, nil +} + +// afterStartup is the G3b hook (inline bundle GC). +func (rc *reconciler) afterStartup(_ *candidate) {} + +// prepareInline is the G3b hook (materialize and check inline policy bundles). +func (rc *reconciler) prepareInline(_ context.Context, _ agentconfig.Config, _ map[string]string) (inlineResult, *applyError) { + return inlineResult{}, 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) + } +} + +func (rc *reconciler) rememberedRejected(rec *agentstate.OverlayRecord) bool { + r := rc.cache.Rejected + return r != nil && rec != nil && r.ETag == rec.ETag && r.BaseFingerprint == rc.base.fingerprint +} + +// recordFailure remembers a rejected revision for (etag, base), or starts the failed backoff. +func (rc *reconciler) recordFailure(target *agentstate.OverlayRecord, aerr *applyError) { + if target == nil { + 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, + BaseFingerprint: rc.base.fingerprint, + Status: aerr.Status, + Reason: aerr.Reason, + Error: msg, + } + rc.saveCache() + return } - return cand, nil + rc.startFailedBackoff(target) } -// prepare builds a candidate from a base. It never touches the running configuration. -func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot) (*candidate, *applyError) { +func (rc *reconciler) startFailedBackoff(target *agentstate.OverlayRecord) { + if rc.failedETag == target.ETag && rc.failedBase == rc.base.fingerprint && rc.failedInterval > 0 { + rc.failedInterval = min(rc.failedInterval*2, failedRetryMax) + } else { + rc.failedInterval = failedRetryMin + } + rc.failedETag, rc.failedBase = target.ETag, rc.base.fingerprint + rc.failedRetryAt = rc.now().Add(rc.failedInterval) +} + +func (rc *reconciler) inFailedBackoff(target *agentstate.OverlayRecord) bool { + return target != nil && rc.failedInterval > 0 && rc.failedETag == target.ETag && + rc.failedBase == rc.base.fingerprint && rc.now().Before(rc.failedRetryAt) +} + +func (rc *reconciler) clearFailedBackoff() { + rc.failedETag, 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 - runtime, err := toRuntime(declared, nil, base.skip) + 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, err := agentconfig.ResolveEnv(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) + } + + inline, aerr := rc.prepareInline(ctx, resolved, part.skip) + if aerr != nil { + return nil, aerr + } + + runtime, err := toRuntime(resolved, inline.dirs, part.skip) if err != nil { - return nil, &applyError{Status: agentconfig.StatusFailed, Reason: agentconfig.ReasonInvalidConfig, Err: err} + return nil, failed(agentconfig.ReasonInvalidConfig, err) } if err := rc.runner.Prefetch(ctx, runtime); err != nil { - return nil, &applyError{Status: agentconfig.StatusFailed, Reason: agentconfig.ReasonDownloadFailed, Err: err} + return nil, failed(agentconfig.ReasonDownloadFailed, err) } + + // 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()...) - runtime.sync = syncMeta{Digest: digest, Mode: runtime.remote.Mode} + runtime.sync = syncMeta{Digest: digest, Mode: rcfg.Mode} + if ov != nil { + runtime.sync.AppliedRevision = ov.Revision + } return &candidate{ - base: base, - declared: declared, - runtime: runtime, - digest: digest, - identity: candidateIdentity(declared), - warnings: base.warnings, + base: base, + overlay: ov, + declared: declared, + runtime: runtime, + digest: digest, + identity: candidateIdentity(declared, inline.dirs), + bundles: inline.reports, + warnings: part.warnings, + policyWarnings: inline.warnings, }, nil } -func candidateIdentity(c agentconfig.Config) string { - raw, err := agentconfig.CanonicalJSON(c) +// inlineResult is what the inline bundle step contributes to a candidate (G3b). +type inlineResult struct { + dirs map[string]string + reports []agentconfig.PolicyBundleReport + warnings []agentconfig.PolicyError +} + +// 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) +} + +func candidateIdentity(c agentconfig.Config, inlineDirs map[string]string) string { + raw, err := agentconfig.CanonicalJSON(struct { + Config agentconfig.Config `json:"config"` + InlineDirs map[string]string `json:"inline_dirs,omitempty"` + }{c, inlineDirs}) if err != nil { raw = []byte(err.Error()) } @@ -197,7 +653,7 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { if runErr != nil && !reload { if previous != nil { rc.logger.Error("Configuration failed to run; falling back to the previous configuration", "error", runErr) - rc.onRunFailed(active, runErr) + rc.notifyRunFailed(active) active, previous = previous, nil continue } @@ -214,47 +670,203 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { } } -// onRunFailed is called when a configuration that passed prepare fails to run on its own. -func (rc *reconciler) onRunFailed(_ *candidate, _ error) {} +// 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. It returns when ctx is done. +// 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(fileDebounce) + debounce = time.After(rc.debounce) } case <-debounce: debounce = nil - rc.reconcileFile(ctx) + rc.reconcile(ctx, triggerFile) + case c := <-rc.runFailed: + rc.onRunFailed(ctx, c) + case <-poll.C: + rc.reconcile(ctx, triggerPoll) + poll.Reset(rc.pollDelay()) } } } -// reconcileFile handles a (debounced) config file change. -func (rc *reconciler) reconcileFile(ctx context.Context) { - base, err := loadBase(rc.cmd, rc.configPath) - if err != nil { - rc.logger.Error("Config file is invalid; keeping the running configuration", "error", err) +// 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 && c.overlay != nil { + rc.startFailedBackoff(c.overlay) + 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 + } + active := rc.current() + if rc.sameAsActive(active, target) { + rc.maybeReport(ctx, active, rc.lastOutcome) return } - rc.base = base - rc.logWarnings(base.warnings) - cand, aerr := rc.prepare(ctx, base) + if rc.inFailedBackoff(target) { + rc.maybeReport(ctx, active, rc.lastOutcome) + return + } + + cand, aerr := rc.prepare(ctx, rc.base, target) if aerr != nil { - rc.logger.Error("Could not prepare the new configuration; keeping the running configuration", "status", aerr.Status, "reason", aerr.Reason, "error", aerr.Err) + 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 } - if cur := rc.current(); cur != nil && cur.identity == cand.identity { - rc.logger.Debug("Config file changed without changing the effective configuration") + rc.clearFailedBackoff() + if isApplyMode(rcfg.Mode) { + rc.cache.Applied = target + rc.saveCache() + } + rc.lastOutcome = nil + if active != nil && active.identity == cand.identity { + rc.logger.Debug("Trigger did not change the effective configuration") + rc.maybeReport(ctx, active, nil) return } - rc.logger.Info("Applying the new configuration") + rc.logger.Info("Applying the new configuration", "revision", revisionForLog(target)) rc.swap(cand) + rc.maybeReport(ctx, cand, nil) +} + +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) { diff --git a/cmd/reconciler_test.go b/cmd/reconciler_test.go index 17a0736..c1e1a28 100644 --- a/cmd/reconciler_test.go +++ b/cmd/reconciler_test.go @@ -147,7 +147,7 @@ func TestReconciler_InvalidEditKeepsRunning(t *testing.T) { if err := os.WriteFile(path, []byte("daemon: true\nplugins: [\n"), 0o600); err != nil { t.Fatal(err) } - rc.reconcileFile(context.Background()) + 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()) @@ -162,13 +162,13 @@ func TestReconciler_ValidEditCancelsOnce(t *testing.T) { if err := os.WriteFile(path, []byte(configWithSchedule("*/5 * * * *")), 0o600); err != nil { t.Fatal(err) } - rc.reconcileFile(context.Background()) + 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.reconcileFile(context.Background()) + 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()) @@ -184,7 +184,7 @@ func TestReconciler_PrefetchFailureDoesNotCancel(t *testing.T) { if err := os.WriteFile(path, []byte(configWithSchedule("*/5 * * * *")), 0o600); err != nil { t.Fatal(err) } - rc.reconcileFile(context.Background()) + rc.reconcile(context.Background(), triggerFile) expectNoStart(t, rec, 200*time.Millisecond) } @@ -202,7 +202,7 @@ func TestReconciler_RunFailureFallsBackToPrevious(t *testing.T) { if err := os.WriteFile(path, []byte(configWithSchedule("*/5 * * * *")), 0o600); err != nil { t.Fatal(err) } - rc.reconcileFile(context.Background()) + 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) } @@ -212,12 +212,9 @@ func TestReconciler_RunFailureFallsBackToPrevious(t *testing.T) { } func TestReconciler_RapidFileEventsRace(t *testing.T) { - old := fileDebounce - fileDebounce = 5 * time.Millisecond - t.Cleanup(func() { fileDebounce = old }) - rc, _, path := newTestReconciler(t, configWithSchedule("* * * * *")) rec := newRunRecorder() + rc.debounce = 5 * time.Millisecond startReconciler(t, rc, rec) ctx, cancel := context.WithCancel(context.Background()) defer cancel() diff --git a/cmd/remote_test.go b/cmd/remote_test.go new file mode 100644 index 0000000..54b26d3 --- /dev/null +++ b/cmd/remote_test.go @@ -0,0 +1,840 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "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" +) + +// 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}" +`) + 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, `"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("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) { + big := strings.Repeat("# padding\n", 30000) // ~300 KiB per module + modules := map[string]string{} + for i := 0; i < 14; i++ { + modules[fmt.Sprintf("m%02d.rego", i)] = "package compliance_framework.m\n" + big + } + report := agentconfig.Report{Mode: "apply_safe", Status: "applied"} + doc := agentconfig.Config{PolicyBundles: map[string]*agentconfig.PolicyBundle{"b": {Modules: modules}}} + report.Base = marshalRaw(doc) + report.Effective = marshalRaw(doc) + body, _, err := fitReport(&report, false) + if err != nil { + t.Fatal(err) + } + if !report.Truncated || len(body) > agentconfig.MaxReportBytes { + t.Fatalf("expected a truncated report under the limit, got truncated=%v size=%d", report.Truncated, len(body)) + } + if !strings.Contains(string(report.Effective), `"sha256:`) { + t.Fatalf("expected modules to be replaced by digests") + } +} + +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"}, + {"R28 mixed-case bundle name", "apply_all", "", `{"policy_bundles":{"MyBundle":{"modules":{"a.rego":"package compliance_framework.a"}}}}`, "rejected", "invalid-config"}, + } + 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) + } +} + +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 an + // error (agentconfig env-location), and an overlay using it there is rejected. + bad := newRemoteHarness(t, remoteConfig("apply_all", "")+` + policy_data: + url: "${env:NOT_RESOLVED}" +`) + if _, err := bad.rc.startup(context.Background()); err == nil || !strings.Contains(err.Error(), "only resolved in plugins.*.config") { + t.Fatalf("expected an env-location error for policy_data in the file, got %v", err) + } + + 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) + } + if !strings.Contains(string(h.remote.lastReport(t).Effective), "${env:HOST}") { + t.Fatal("the report must carry the unresolved placeholder") + } + 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) + } +} + +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 + 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() + <-ctx.Done() + mu.Lock() + running-- + mu.Unlock() + return nil + }) + }() + + 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") + } +} diff --git a/cmd/report.go b/cmd/report.go new file mode 100644 index 0000000..8adc6cf --- /dev/null +++ b/cmd/report.go @@ -0,0 +1,226 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "os" + "runtime/debug" + "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 +} + +// 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, + PolicyBundles: append([]agentconfig.PolicyBundleReport(nil), active.bundles...), + Warnings: active.warnings, + RemoteConfig: &rcfg, + } + report.PolicyErrors = append(report.PolicyErrors, active.policyWarnings...) + 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 + report.PolicyErrors = append(append([]agentconfig.PolicyError(nil), outcome.PolicyErrors...), report.PolicyErrors...) + 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): first every policy bundle module in base and +// effective becomes "sha256:", then the policy bundle file lists are dropped, then base +// is dropped. Any step 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){hashReportModules, dropReportFileLists, 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 hashReportModules(report *agentconfig.Report) { + report.Base = hashDocModules(report.Base) + report.Effective = hashDocModules(report.Effective) +} + +// hashDocModules replaces policy_bundles.*.modules.* values with "sha256:". +func hashDocModules(doc json.RawMessage) json.RawMessage { + var obj map[string]any + dec := json.NewDecoder(strings.NewReader(string(doc))) + dec.UseNumber() + if err := dec.Decode(&obj); err != nil { + return doc + } + bundles, _ := obj["policy_bundles"].(map[string]any) + for _, raw := range bundles { + b, _ := raw.(map[string]any) + modules, _ := b["modules"].(map[string]any) + for p, src := range modules { + if s, ok := src.(string); ok { + sum := sha256.Sum256([]byte(s)) + modules[p] = "sha256:" + hex.EncodeToString(sum[:]) + } + } + } + out, err := json.Marshal(obj) + if err != nil { + return doc + } + return out +} + +func dropReportFileLists(report *agentconfig.Report) { + for i := range report.PolicyBundles { + report.PolicyBundles[i].Files = []agentconfig.PolicyFileReport{} + if report.PolicyBundles[i].Extends != nil { + ext := *report.PolicyBundles[i].Extends + ext.Files = []agentconfig.PolicyFileReport{} + report.PolicyBundles[i].Extends = &ext + } + } +} + +func dropReportBase(report *agentconfig.Report) { + report.Base = json.RawMessage(`{}`) +} diff --git a/internal/agentstate/cache.go b/internal/agentstate/cache.go new file mode 100644 index 0000000..8c47846 --- /dev/null +++ b/internal/agentstate/cache.go @@ -0,0 +1,135 @@ +package agentstate + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +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. +type RejectedRecord struct { + Revision int64 `json:"revision"` + ETag string `json:"etag"` + BaseFingerprint string `json:"base_fingerprint"` + Status string `json:"status"` + Reason string `json:"reason"` + Error string `json:"error"` +} + +// 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/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/runner/result.go b/runner/result.go index 8a4c227..615cd84 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 } type ApiHelperOption func(*apiHelper) @@ -31,6 +33,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{ @@ -142,9 +153,27 @@ func (h *apiHelper) toSdk(e *proto.Evidence, refs *types.PolicyArtifacts) types. 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]) + } +} From 172b921626ede7956d1b2390eed27a3cc67550c9 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:35:13 -0300 Subject: [PATCH 05/47] feat(agent): inline policy bundles (G3b); pin api to approved aa005f7 Re-pin compliance-framework/api to the approved PR #465 head (aa005f7; only PolicyOnlyChange changed, the agent-facing contract did not). internal/inlinepolicy materializes policy_bundles (file or overlay) into write-once dirs under /inline//: - R17 order: extends tree (regular files only, symlinks skipped with a warning), delete, modules, then data merge-patched onto the root data file and written as data.json (a base data.yaml is converted); - R18: root-relative paths, re-checked after Clean; only data.json / data.yaml / data.yml data files (authored: error, vendor: warning); - Check runs per (plugin, policy path), the compile unit plugins use (R21): the same policyeval.NewFromBundlePath prepare path as policy-manager, a walk of every rule reachable from authored rules for policyeval.DeniedBuiltins refs (including with ... as http.send, R19/R20), and the Rego tests with bundle data + policy_data (authored failures reject, vendor failures warn); - GC keeps the active dirs plus the 5 newest per bundle (startup only). prepare runs regocheck first, then materialize + check; errors give rejected/policy-errors with located errors, warnings are reported. DownloadPolicies/runPlugin use the materialized dir for inline: and never download it. Reports list inline bundles with their extends tree and inventory OCI/local policy paths. --- cmd/agent.go | 15 + cmd/inline.go | 217 +++++++++++ cmd/inline_test.go | 124 +++++++ cmd/reconciler.go | 21 +- go.mod | 2 +- go.sum | 4 +- internal/inlinepolicy/check.go | 262 +++++++++++++ internal/inlinepolicy/inlinepolicy_test.go | 407 +++++++++++++++++++++ internal/inlinepolicy/materialize.go | 394 ++++++++++++++++++++ 9 files changed, 1434 insertions(+), 12 deletions(-) create mode 100644 cmd/inline.go create mode 100644 cmd/inline_test.go create mode 100644 internal/inlinepolicy/check.go create mode 100644 internal/inlinepolicy/inlinepolicy_test.go create mode 100644 internal/inlinepolicy/materialize.go diff --git a/cmd/agent.go b/cmd/agent.go index e6d257c..92dcea7 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -335,6 +335,9 @@ func agentRunner(cmd *cobra.Command, args []string) error { ar := NewAgentRunner(WithInstanceID(id)) rc := newReconciler(cmd, configPath, store, ar, logger) rc.instanceID = id + rc.resolvePolicy = func(ctx context.Context, source string) (string, error) { + return ar.downloadPolicy(ctx, source, logger) + } active, err := rc.startup(context.Background()) if err != nil { @@ -1447,6 +1450,10 @@ func (ar *AgentRunner) runPlugin(ctx context.Context, name string, plugin *agent policyPaths := make([]string, 0) for _, inputBundle := range plugin.Policies { + if dir, ok := config.inlinePolicyDirs[string(inputBundle)]; ok { + policyPaths = append(policyPaths, dir) + continue + } policyLocation, err := ar.download(ctx, string(inputBundle), AgentPolicyDir, "policies", "", logger) if err != nil { return err @@ -1898,6 +1905,11 @@ func (ar *AgentRunner) DownloadPolicies(ctx context.Context) error { } for source := range policySources { + // Inline bundles were materialized by the reconciler; they are never downloaded. + if dir, ok := config.inlinePolicyDirs[source]; ok { + ar.policyLocations[source] = dir + continue + } out, err := ar.download(ctx, source, AgentPolicyDir, "policies", "", logger) if err != nil { @@ -1965,6 +1977,9 @@ func (ar *AgentRunner) Prefetch(ctx context.Context, cfg *agentConfig) error { // 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) } diff --git a/cmd/inline.go b/cmd/inline.go new file mode 100644 index 0000000..2fa4da3 --- /dev/null +++ b/cmd/inline.go @@ -0,0 +1,217 @@ +package cmd + +import ( + "context" + "errors" + "path/filepath" + "sort" + + "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/pkg/agentconfig/regocheck" +) + +// inlineGCKeepPerBundle is how many materialized versions of each bundle GC keeps besides the +// active ones. +const inlineGCKeepPerBundle = 5 + +// inlineRoot is where inline bundles are materialized (under the state dir, R31). +func (rc *reconciler) inlineRoot() string { + return filepath.Join(rc.store.Dir(), "inline") +} + +// prepareInline is prepare step 8 (G3b): the parse-level checks on every bundle, then +// materialize each bundle an enabled plugin references and check it per (plugin, path) the +// way the plugin will load it. Any error rejects the revision (policy-errors); warnings are +// kept for the report. No network is touched before the Classify gate: extends trees are +// fetched here, after it. +func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Config, skip map[string]string) (inlineResult, *applyError) { + var res inlineResult + if len(resolved.PolicyBundles) == 0 { + return res, nil + } + + static := regocheck.ValidatePolicyBundles(resolved.PolicyBundles) + if agentconfig.HasPolicyErrors(static) { + return res, policyRejection(static) + } + res.warnings = append(res.warnings, static...) + + // Bundles referenced by the plugins that will run. + refs := map[string]bool{} + for name, p := range resolved.Plugins { + if p == nil || !p.IsEnabled() { + continue + } + if _, skipped := skip[name]; skipped { + continue + } + for _, e := range p.Policies { + if b, ok := agentconfig.InlineBundleName(e); ok { + refs[b] = true + } + } + } + if len(refs) == 0 { + return res, nil + } + + materialized := map[string]*inlinepolicy.Materialized{} + var problems []agentconfig.PolicyError + for _, name := range sortedBoolKeys(refs) { + m, err := inlinepolicy.Materialize(ctx, rc.inlineRoot(), name, resolved.PolicyBundles[name], rc.resolvePolicy) + var perrs inlinepolicy.PolicyErrors + switch { + case errors.As(err, &perrs): + problems = append(problems, perrs...) + continue + case errors.Is(err, inlinepolicy.ErrResolve): + return res, failed(agentconfig.ReasonDownloadFailed, err) + case err != nil: + return res, failed(agentconfig.ReasonInternal, err) + } + materialized[name] = m + res.warnings = append(res.warnings, m.Warnings...) + } + if agentconfig.HasPolicyErrors(problems) { + return res, policyRejection(append(problems, res.warnings...)) + } + + // One compile unit per (plugin, policy path) (R21): plugins with different policy_data + // are checked separately. + for _, pluginName := range sortedPluginNames(resolved.Plugins) { + p := resolved.Plugins[pluginName] + if p == nil || !p.IsEnabled() { + continue + } + if _, skipped := skip[pluginName]; skipped { + continue + } + for _, e := range p.Policies { + name, ok := agentconfig.InlineBundleName(e) + if !ok || materialized[name] == nil { + continue + } + m := materialized[name] + problems = append(problems, inlinepolicy.Check(ctx, inlinepolicy.CheckInput{ + Plugin: pluginName, + Bundle: name, + PolicyDir: m.Dir, + Authored: m.Authored, + AuthoredTests: m.AuthoredTests, + PolicyData: p.PolicyData, + })...) + } + } + if agentconfig.HasPolicyErrors(problems) { + return res, policyRejection(append(problems, res.warnings...)) + } + res.warnings = append(res.warnings, problems...) + agentconfig.SortPolicyErrors(res.warnings) + + res.dirs = map[string]string{} + for _, name := range sortedMaterializedKeys(materialized) { + m := materialized[name] + res.dirs[agentconfig.InlineSourcePrefix+name] = m.Dir + res.reports = append(res.reports, agentconfig.PolicyBundleReport{ + Source: agentconfig.InlineSourcePrefix + name, + Digest: m.Digest, + Extends: m.Extends, + Files: m.Files, + }) + } + return res, nil +} + +func policyRejection(errs []agentconfig.PolicyError) *applyError { + agentconfig.SortPolicyErrors(errs) + var only []agentconfig.PolicyError + for _, e := range errs { + if e.Severity == agentconfig.SeverityError { + only = append(only, e) + } + } + aerr := rejected(agentconfig.ReasonPolicyErrors, inlinepolicy.PolicyErrors(only)) + aerr.PolicyErrors = errs + return aerr +} + +// sourceReports inventories the non-inline policy paths of runtime for the report. OCI trees +// are memoized per (source, dir); local trees are re-read. +func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) []agentconfig.PolicyBundleReport { + if rc.resolvePolicy == nil { + return nil + } + sources := map[string]struct{}{} + for _, p := range runtime.Plugins { + for _, e := range p.Policies { + if _, inline := runtime.inlinePolicyDirs[string(e)]; !inline { + sources[string(e)] = struct{}{} + } + } + } + var out []agentconfig.PolicyBundleReport + for _, source := range sortedSetKeys(sources) { + dir, err := rc.resolvePolicy(ctx, source) + if err != nil { + continue + } + key := source + "\x00" + dir + if r, ok := rc.inventoryMemo[key]; ok { + out = append(out, r) + continue + } + digest, files, err := inlinepolicy.Inventory(dir) + if err != nil { + continue + } + r := agentconfig.PolicyBundleReport{Source: source, Digest: digest, Files: files} + if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { + rc.inventoryMemo[key] = r + } + out = append(out, r) + } + return out +} + +// afterStartup removes materialized inline bundles that are neither active nor among the +// newest per bundle. It runs only at the end of startup. +func (rc *reconciler) afterStartup(active *candidate) { + if active == nil || !rc.store.Writable() { + return + } + keep := map[string]struct{}{} + for _, dir := range active.runtime.inlinePolicyDirs { + keep[dir] = struct{}{} + } + if err := inlinepolicy.GC(rc.inlineRoot(), keep, inlineGCKeepPerBundle); err != nil { + rc.logger.Warn("Could not clean up old inline policy bundles", "error", err) + } +} + +func sortedBoolKeys(m map[string]bool) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortedMaterializedKeys(m map[string]*inlinepolicy.Materialized) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortedPluginNames(m map[string]*agentconfig.Plugin) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/inline_test.go b/cmd/inline_test.go new file mode 100644 index 0000000..a9e73dc --- /dev/null +++ b/cmd/inline_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/compliance-framework/api/pkg/agentconfig" +) + +const inlineBaseConfig = ` +daemon: true +api: + url: http://api.test + auth: + client_id: 123e4567-e89b-12d3-a456-426614174000 + client_secret: s3cret +remote_config: + mode: apply_safe +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-ssh:v1 + policies: ["inline:ssh"] + policy_data: + max: 3 +policy_bundles: + ssh: + extends: ghcr.io/vendor/policies:v1 + modules: + extra.rego: | + package compliance_framework.extra + + import rego.v1 + + violation contains {"remarks": "x"} if input.max > data.max +` + +func newInlineHarness(t *testing.T) (*remoteHarness, string) { + t.Helper() + vendor := t.TempDir() + if err := os.WriteFile(filepath.Join(vendor, "banner.rego"), []byte("package compliance_framework.banner\n\nimport rego.v1\n\nviolation contains {\"remarks\": \"b\"} if not input.banner\n"), 0o644); err != nil { + t.Fatal(err) + } + h := newRemoteHarness(t, inlineBaseConfig) + h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { + if source == "ghcr.io/vendor/policies:v1" { + return vendor, nil + } + return "", errors.New("unknown source " + source) + } + return h, vendor +} + +func TestInline_FileBundleMaterializedAndReported(t *testing.T) { + h, _ := newInlineHarness(t) + active := mustStartup(t, h.rc) + dir, ok := active.runtime.inlinePolicyDirs["inline:ssh"] + if !ok || !strings.HasPrefix(dir, filepath.Join(h.dir, "state", "inline", "ssh")) { + t.Fatalf("inline bundle not materialized under the state dir: %v", active.runtime.inlinePolicyDirs) + } + for _, f := range []string{"banner.rego", "extra.rego"} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + t.Fatalf("%s missing from the materialized tree: %v", f, err) + } + } + r := h.remote.lastReport(t) + if len(r.PolicyBundles) != 1 || r.PolicyBundles[0].Source != "inline:ssh" || r.PolicyBundles[0].Extends == nil || len(r.PolicyBundles[0].Extends.Files) != 1 || len(r.PolicyBundles[0].Files) != 2 { + t.Fatalf("unexpected policy-bundles %+v", r.PolicyBundles) + } +} + +func TestInline_OverlayParseErrorKeepsRunning(t *testing.T) { + h, _ := newInlineHarness(t) + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nviolation contains x if {"}}}}`) + active := mustStartup(t, h.rc) + if active.overlay != nil { + t.Fatal("a policy with a parse error must not be applied") + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors { + t.Fatalf("expected rejected/policy-errors, got %s/%s", r.Status, r.Reason) + } + found := false + for _, e := range r.PolicyErrors { + if e.Severity == agentconfig.SeverityError && e.Path == "extra.rego" && e.Row > 0 && e.Col > 0 { + found = true + } + } + if !found { + t.Fatalf("expected a located policy error, got %+v", r.PolicyErrors) + } +} + +func TestInline_TransitiveDeniedBuiltinRejected(t *testing.T) { + h, vendor := newInlineHarness(t) + if err := os.WriteFile(filepath.Join(vendor, "lib.rego"), []byte("package ccf_libs.net\n\nimport rego.v1\n\nfetch(u) := http.send({\"method\": \"GET\", \"url\": u})\n"), 0o644); err != nil { + t.Fatal(err) + } + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\nimport data.ccf_libs.net\n\nviolation contains {\"remarks\": r} if r := net.fetch(\"http://x\")\n"}}}}`) + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors || !strings.Contains(*r.Error, "http.send") { + t.Fatalf("expected the helper-wrapped http.send to be rejected, got %s/%s %v", r.Status, r.Reason, derefString(r.Error)) + } +} + +func TestInline_DownloadPoliciesNeverDownloadsInline(t *testing.T) { + ar := NewAgentRunner() + ar.UpdateConfig(&agentConfig{ + Plugins: map[string]*agentPlugin{ + "ssh": {Source: "/tmp/plugin", Policies: []agentPolicy{"inline:ssh"}}, + }, + inlinePolicyDirs: map[string]string{"inline:ssh": "/materialized/ssh"}, + }) + if err := ar.DownloadPolicies(context.Background()); err != nil { + t.Fatalf("an inline source must not be downloaded: %v", err) + } + if got := ar.policyLocations["inline:ssh"]; got != "/materialized/ssh" { + t.Fatalf("policy location = %q", got) + } +} diff --git a/cmd/reconciler.go b/cmd/reconciler.go index ff2b1eb..2854ff8 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -16,6 +16,7 @@ import ( "time" "github.com/compliance-framework/agent/internal/agentstate" + "github.com/compliance-framework/agent/internal/inlinepolicy" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" "github.com/fsnotify/fsnotify" @@ -172,7 +173,12 @@ type reconciler struct { newRemote func(agentconfig.Config) remoteAPI // lookupEnv resolves ${env:NAME} placeholders (a test seam). lookupEnv func(string) (string, bool) - now func() time.Time + // resolvePolicy returns the policy root of an OCI or local policy source (downloading it + // into the shared cache); it serves inline bundles' extends and the report inventory. + resolvePolicy inlinepolicy.Resolver + // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"). + inventoryMemo map[string]agentconfig.PolicyBundleReport + now func() time.Time mu sync.Mutex // guards active, pending, cancelRun active *candidate @@ -217,6 +223,8 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor lookupEnv: os.LookupEnv, now: time.Now, loggedOnce: map[string]bool{}, + + inventoryMemo: map[string]agentconfig.PolicyBundleReport{}, } } @@ -353,14 +361,6 @@ func (rc *reconciler) startup(ctx context.Context) (*candidate, error) { return active, nil } -// afterStartup is the G3b hook (inline bundle GC). -func (rc *reconciler) afterStartup(_ *candidate) {} - -// prepareInline is the G3b hook (materialize and check inline policy bundles). -func (rc *reconciler) prepareInline(_ context.Context, _ agentconfig.Config, _ map[string]string) (inlineResult, *applyError) { - return inlineResult{}, nil -} - // ladder lists the startup targets in order; nil is the file only. func (rc *reconciler) ladder(mode string) []*agentstate.OverlayRecord { if !isApplyMode(mode) { @@ -506,6 +506,9 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent if err := rc.runner.Prefetch(ctx, runtime); err != nil { return nil, failed(agentconfig.ReasonDownloadFailed, err) } + if rcfg.Mode != agentconfig.ModeOff { + inline.reports = append(inline.reports, rc.sourceReports(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. diff --git a/go.mod b/go.mod index d27b1d9..8070e69 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.19.1-0.20260930145709-52e315b5ca04 + github.com/compliance-framework/api v0.19.1-0.20260930152307-aa005f7646ab 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 diff --git a/go.sum b/go.sum index 2d28179..ca37e8d 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.19.1-0.20260930145709-52e315b5ca04 h1:P5zwVj0+CYnGueMPc8KspTDJeCumZtfguFU92Rc5faI= -github.com/compliance-framework/api v0.19.1-0.20260930145709-52e315b5ca04/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.19.1-0.20260930152307-aa005f7646ab h1:b+BmYw1eOD8OkILydtiRhu2Ip71tqMn/fVsjxHLa5lo= +github.com/compliance-framework/api v0.19.1-0.20260930152307-aa005f7646ab/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= diff --git a/internal/inlinepolicy/check.go b/internal/inlinepolicy/check.go new file mode 100644 index 0000000..b4433c9 --- /dev/null +++ b/internal/inlinepolicy/check.go @@ -0,0 +1,262 @@ +package inlinepolicy + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/pkg/policyeval" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/loader" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/storage/inmem" + "github.com/open-policy-agent/opa/v1/tester" +) + +// TestTimeout bounds the Rego tests of one (plugin, policy path) check. +var TestTimeout = 30 * time.Second + +// CheckInput is one plugin × materialized policy path. +type CheckInput struct { + Plugin, Bundle string + PolicyDir string + Authored map[string]bool + AuthoredTests []string + PolicyData map[string]any +} + +// Check compiles and tests one materialized bundle exactly the way a plugin will load it +// (R3, R21): one compile unit per policy path, through the same policyeval constructor and +// prepare path policy-manager uses. It returns errors (the revision is rejected) and warnings: +// +// 1. parity compile: every compile error is an error; +// 2. denied builtins (R19, R20): any policyeval.DeniedBuiltins ref reachable from an authored +// rule through the compiled rule graph — including `with f as http.send` — is an error; +// 3. tests: a failing authored _test.rego test is an error, a failing vendor test a warning. +// +// The parse-level checks (regocheck) run once per bundle before materialization. +func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + add := func(severity string, loc *ast.Location, format string, args ...any) { + e := agentconfig.PolicyError{Bundle: in.Bundle, Message: fmt.Sprintf(format, args...), Severity: severity} + if in.Plugin != "" { + e.Message = fmt.Sprintf("plugin %s: %s", in.Plugin, e.Message) + } + if loc != nil { + e.Path = relPath(in.PolicyDir, loc.File) + e.Row, e.Col = loc.Row, loc.Col + } + out = append(out, e) + } + + // 1. Parity compile. + _, err := policyeval.NewFromBundlePath(in.PolicyDir, in.PolicyData, policyeval.Options{}). + PrepareForEval(ctx, rego.Query("data.compliance_framework"), rego.Package("compliance_framework")) + if err != nil { + addCompileErrors(err, func(loc *ast.Location, msg string) { + add(agentconfig.SeverityError, loc, "%s", strings.ReplaceAll(msg, in.PolicyDir+string(filepath.Separator), "")) + }) + agentconfig.SortPolicyErrors(out) + return out + } + + b, err := loader.NewFileLoader().WithRegoVersion(ast.RegoV1).AsBundle(in.PolicyDir) + if err != nil { + add(agentconfig.SeverityError, nil, "load bundle: %s", err.Error()) + return out + } + modules := make(map[string]*ast.Module, len(b.Modules)) + for _, mf := range b.Modules { + modules[mf.Path] = mf.Parsed + } + compiler := ast.NewCompiler() + if compiler.Compile(modules); compiler.Failed() { + addCompileErrors(compiler.Errors, func(loc *ast.Location, msg string) { add(agentconfig.SeverityError, loc, "%s", msg) }) + agentconfig.SortPolicyErrors(out) + return out + } + + // 2. Transitive denied builtins. + for _, h := range deniedReachable(compiler, in.PolicyDir, in.Authored) { + add(agentconfig.SeverityError, h.loc, "forbidden builtin %s is reachable from authored rule %s", h.name, h.from) + } + + // 3. Tests. + out = append(out, runTests(ctx, in, b.Data, modules)...) + agentconfig.SortPolicyErrors(out) + return out +} + +func addCompileErrors(err error, add func(loc *ast.Location, msg string)) { + var astErrs ast.Errors + switch e := err.(type) { + case ast.Errors: + astErrs = e + case *ast.Error: + astErrs = ast.Errors{e} + default: + var single *ast.Error + if errors.As(err, &astErrs) { + break + } + if errors.As(err, &single) { + astErrs = ast.Errors{single} + } + } + if len(astErrs) == 0 { + add(nil, err.Error()) + return + } + for _, e := range astErrs { + add(e.Location, e.Message) + } +} + +// relPath makes a module file path relative to the policy root (slash-separated). +func relPath(root, file string) string { + if file == "" { + return "" + } + if rel, err := filepath.Rel(root, file); err == nil && !strings.HasPrefix(rel, "..") { + return filepath.ToSlash(rel) + } + return filepath.ToSlash(file) +} + +type deniedHit struct { + name string + loc *ast.Location + from string +} + +// deniedReachable walks every rule of the authored modules and, through data refs, every rule +// they can reach, and reports each denied builtin ref (calls, `with ... as `, any +// position). +func deniedReachable(c *ast.Compiler, root string, authored map[string]bool) []deniedHit { + var hits []deniedHit + seenHit := map[string]bool{} + visited := map[*ast.Rule]bool{} + opts := ast.RulesOptions{IncludeHiddenModules: true} + + var visit func(rule *ast.Rule, from string) + visit = func(rule *ast.Rule, from string) { + if visited[rule] { + return + } + visited[rule] = true + ast.WalkRefs(rule, func(ref ast.Ref) bool { + if len(ref) == 0 { + return false + } + name := ref.String() + if slices.Contains(policyeval.DeniedBuiltins, name) { + loc := ref[0].Location + key := name + if loc != nil { + key = fmt.Sprintf("%s:%s:%d:%d", name, loc.File, loc.Row, loc.Col) + } + if !seenHit[key] { + seenHit[key] = true + hits = append(hits, deniedHit{name: name, loc: loc, from: from}) + } + return false + } + if ref.HasPrefix(ast.DefaultRootRef) { + for _, r := range c.GetRulesDynamicWithOpts(ref, opts) { + visit(r, from) + } + } + return false + }) + if rule.Else != nil { + visit(rule.Else, from) + } + } + + for _, path := range sortedKeys(c.Modules) { + if !authored[relPath(root, path)] { + continue + } + mod := c.Modules[path] + for _, rule := range mod.Rules { + from := strings.TrimPrefix(rule.Ref().String(), "data.") + if mod.Package != nil { + from = strings.TrimPrefix(mod.Package.Path.String(), "data.") + "." + rule.Head.Ref().String() + } + visit(rule, from) + } + } + return hits +} + +// runTests runs the bundle's Rego tests against the bundle data merged with the plugin's +// policy_data. Authored test failures are errors, vendor test failures warnings (R21). +func runTests(ctx context.Context, in CheckInput, bundleData map[string]any, modules map[string]*ast.Module) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + testCtx, cancel := context.WithTimeout(ctx, TestTimeout) + defer cancel() + store := inmem.NewFromObject(mergePolicyData(bundleData, in.PolicyData)) + ch, err := tester.NewRunner(). + SetCompiler(ast.NewCompiler()). + SetStore(store). + SetModules(modules). + SetTimeout(TestTimeout). + RunTests(testCtx, nil) + if err != nil { + return []agentconfig.PolicyError{{Bundle: in.Bundle, Message: prefixPlugin(in.Plugin, "run tests: "+err.Error()), Severity: agentconfig.SeverityError}} + } + for res := range ch { + if res == nil || res.Pass() || res.Skip { + continue + } + e := agentconfig.PolicyError{Bundle: in.Bundle, Severity: agentconfig.SeverityWarning} + if res.Location != nil { + e.Path = relPath(in.PolicyDir, res.Location.File) + e.Row, e.Col = res.Location.Row, res.Location.Col + } + if in.Authored[e.Path] || slices.Contains(in.AuthoredTests, e.Path) { + e.Severity = agentconfig.SeverityError + } + msg := fmt.Sprintf("test %s.%s failed", strings.TrimPrefix(res.Package, "data."), res.Name) + if res.Error != nil { + msg = fmt.Sprintf("test %s.%s errored: %v", strings.TrimPrefix(res.Package, "data."), res.Name, res.Error) + } + e.Message = prefixPlugin(in.Plugin, msg) + out = append(out, e) + } + if testCtx.Err() != nil && ctx.Err() == nil { + out = append(out, agentconfig.PolicyError{Bundle: in.Bundle, Message: prefixPlugin(in.Plugin, fmt.Sprintf("tests timed out after %s", TestTimeout)), Severity: agentconfig.SeverityError}) + } + return out +} + +func prefixPlugin(plugin, msg string) string { + if plugin == "" { + return msg + } + return fmt.Sprintf("plugin %s: %s", plugin, msg) +} + +// mergePolicyData mirrors policyeval's unexported writePolicyData: nested maps merge +// recursively, anything else replaces. +func mergePolicyData(base, overlay map[string]any) map[string]any { + out := map[string]any{} + for k, v := range base { + out[k] = v + } + for k, v := range overlay { + if vm, ok := v.(map[string]any); ok { + if bm, ok := out[k].(map[string]any); ok { + out[k] = mergePolicyData(bm, vm) + continue + } + } + out[k] = v + } + return out +} diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go new file mode 100644 index 0000000..c3d3c46 --- /dev/null +++ b/internal/inlinepolicy/inlinepolicy_test.go @@ -0,0 +1,407 @@ +package inlinepolicy + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/pkg/policyeval" + "github.com/open-policy-agent/opa/v1/rego" +) + +// vendorTree writes files under a new directory and returns a resolver serving it. +func vendorTree(t *testing.T, files map[string]string) (string, Resolver) { + t.Helper() + dir := t.TempDir() + for p, content := range files { + dst := filepath.Join(dir, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return dir, func(_ context.Context, source string) (string, error) { + if source != "ghcr.io/vendor/policies:v1" { + return "", errors.New("unknown source " + source) + } + return dir, nil + } +} + +func strptr(s string) *string { return &s } + +func readFile(t *testing.T, dir, p string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p))) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + return string(raw) +} + +const vendorBanner = "package compliance_framework.banner\n\nviolation contains {\"remarks\": \"no banner\"} if not input.banner\n" +const vendorMaxAuth = "package compliance_framework.max_auth\n\nviolation contains {\"remarks\": \"too many\"} if input.max_auth > 3\n" + +func TestMaterialize_R17Order(t *testing.T) { + _, resolve := vendorTree(t, map[string]string{ + "banner.rego": vendorBanner, + "max_auth.rego": vendorMaxAuth, + "legacy.rego": "package compliance_framework.legacy\n", + "data.yaml": "limits:\n max_auth: 3\n keep: true\n", + "lib/helpers.rego": "package ccf_libs.helpers\n", + "lib/ignored.json": "{}", + "banner_test.rego": "package compliance_framework.banner_test\n", + "sub/data.json": `{"x": 1}`, + "docs/README.md": "# vendor", + "max_auth_test.rego": "package compliance_framework.max_auth_test\n", + "nested/deep/a.rego": "package compliance_framework.a\n", + "nested/deep/data.yml": "k: v\n", + }) + b := &agentconfig.PolicyBundle{ + Extends: strptr("ghcr.io/vendor/policies:v1"), + Delete: []string{"legacy.rego", "missing.rego"}, + Modules: map[string]string{ + "max_auth.rego": "package compliance_framework.max_auth\n# override\n", + "extra/new.rego": "package compliance_framework.extra\n", + "Policies/Max.Auth.rego": "package compliance_framework.mixed\n", + }, + Data: map[string]any{"limits": map[string]any{"max_auth": 5, "keep": nil}}, + } + root := t.TempDir() + m, err := Materialize(context.Background(), root, "ssh", b, resolve) + if err != nil { + t.Fatalf("materialize: %v", err) + } + if _, err := os.Stat(filepath.Join(m.Dir, "legacy.rego")); !os.IsNotExist(err) { + t.Fatal("deleted vendor module must be gone") + } + if got := readFile(t, m.Dir, "max_auth.rego"); !strings.Contains(got, "# override") { + t.Fatalf("override not applied: %q", got) + } + if got := readFile(t, m.Dir, "banner.rego"); got != vendorBanner { + t.Fatalf("inherited module changed: %q", got) + } + readFile(t, m.Dir, "extra/new.rego") + readFile(t, m.Dir, "Policies/Max.Auth.rego") + var data map[string]any + if err := json.Unmarshal([]byte(readFile(t, m.Dir, "data.json")), &data); err != nil { + t.Fatal(err) + } + if want := map[string]any{"limits": map[string]any{"max_auth": float64(5)}}; !reflect.DeepEqual(data, want) { + t.Fatalf("data.yaml must be merge-patched into data.json: %#v", data) + } + if _, err := os.Stat(filepath.Join(m.Dir, "data.yaml")); !os.IsNotExist(err) { + t.Fatal("the root data.yaml must be replaced by data.json") + } + var warned []string + for _, w := range m.Warnings { + warned = append(warned, w.Path) + } + if !contains(warned, "missing.rego") || !contains(warned, "lib/ignored.json") { + t.Fatalf("expected warnings for the missing delete and the stray vendor data file, got %v", warned) + } + if !m.Authored["max_auth.rego"] || m.Authored["banner.rego"] || !m.Authored["data.json"] { + t.Fatalf("authored set wrong: %v", m.Authored) + } + if m.Extends == nil || m.Extends.Source != "ghcr.io/vendor/policies:v1" || len(m.Extends.Files) != 12 { + t.Fatalf("extends report wrong: %+v", m.Extends) + } + if !strings.HasPrefix(m.Digest, agentconfig.TreeDigestPrefix) || filepath.Base(m.Dir) != strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix) { + t.Fatalf("digest/dir mismatch: %s %s", m.Digest, m.Dir) + } + for _, f := range m.Files { + if f.Path == "banner.rego" && f.Package != "compliance_framework.banner" { + t.Fatalf("package not inventoried: %+v", f) + } + } + + again, err := Materialize(context.Background(), root, "ssh", b, resolve) + if err != nil || again.Dir != m.Dir { + t.Fatalf("the same content must reuse the same directory: %v %s %s", err, again.Dir, m.Dir) + } +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +func TestMaterialize_OverlayNullRestoresVendorModule(t *testing.T) { + _, resolve := vendorTree(t, map[string]string{"max_auth.rego": vendorMaxAuth}) + base := agentconfig.Config{PolicyBundles: map[string]*agentconfig.PolicyBundle{"ssh": { + Extends: strptr("ghcr.io/vendor/policies:v1"), + Modules: map[string]string{"max_auth.rego": "package compliance_framework.max_auth\n# file override\n"}, + }}} + merged, err := agentconfig.Merge(base, json.RawMessage(`{"policy_bundles":{"ssh":{"modules":{"max_auth.rego":null}}}}`)) + if err != nil { + t.Fatal(err) + } + m, err := Materialize(context.Background(), t.TempDir(), "ssh", merged.PolicyBundles["ssh"], resolve) + if err != nil { + t.Fatal(err) + } + if got := readFile(t, m.Dir, "max_auth.rego"); got != vendorMaxAuth { + t.Fatalf("null must restore the vendor module, got %q", got) + } +} + +func TestMaterialize_Errors(t *testing.T) { + _, resolve := vendorTree(t, map[string]string{"a.rego": "package compliance_framework.a\n"}) + tests := []struct { + name string + b *agentconfig.PolicyBundle + }{ + {"data and data.json", &agentconfig.PolicyBundle{Modules: map[string]string{"data.json": "{}"}, Data: map[string]any{"a": 1}}}, + {"stray json module", &agentconfig.PolicyBundle{Modules: map[string]string{"foo.json": "{}"}}}, + {"parent path", &agentconfig.PolicyBundle{Modules: map[string]string{"../x.rego": "package x"}}}, + {"absolute path", &agentconfig.PolicyBundle{Modules: map[string]string{"/abs.rego": "package x"}}}, + {"escaping path", &agentconfig.PolicyBundle{Modules: map[string]string{"a/../../x.rego": "package x"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Materialize(context.Background(), t.TempDir(), "b", tt.b, resolve) + var perrs PolicyErrors + if !errors.As(err, &perrs) || !agentconfig.HasPolicyErrors(perrs) { + t.Fatalf("expected policy errors, got %v", err) + } + }) + } + t.Run("resolver failure", func(t *testing.T) { + _, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/other:v1")}, resolve) + if !errors.Is(err, ErrResolve) { + t.Fatalf("expected ErrResolve, got %v", err) + } + }) +} + +func TestMaterialize_SkipsSymlinksInExtends(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks") + } + dir, resolve := vendorTree(t, map[string]string{"a.rego": "package compliance_framework.a\n"}) + secret := filepath.Join(t.TempDir(), "secret.rego") + if err := os.WriteFile(secret, []byte("package secret\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(secret, filepath.Join(dir, "link.rego")); err != nil { + t.Fatal(err) + } + m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(m.Dir, "link.rego")); !os.IsNotExist(err) { + t.Fatal("a symlink must not be materialized") + } + if len(m.Warnings) != 1 || m.Warnings[0].Path != "link.rego" { + t.Fatalf("expected a symlink warning, got %+v", m.Warnings) + } +} + +func materialize(t *testing.T, vendor map[string]string, b *agentconfig.PolicyBundle) *Materialized { + t.Helper() + _, resolve := vendorTree(t, vendor) + m, err := Materialize(context.Background(), t.TempDir(), "b", b, resolve) + if err != nil { + t.Fatalf("materialize: %v", err) + } + return m +} + +func check(m *Materialized, policyData map[string]any) []agentconfig.PolicyError { + return Check(context.Background(), CheckInput{Plugin: "ssh", Bundle: m.Name, PolicyDir: m.Dir, Authored: m.Authored, AuthoredTests: m.AuthoredTests, PolicyData: policyData}) +} + +func errorsOf(errs []agentconfig.PolicyError, severity string) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + for _, e := range errs { + if e.Severity == severity { + out = append(out, e) + } + } + return out +} + +func TestCheck_CompileAndBuiltins(t *testing.T) { + vendor := map[string]string{ + "lib/net.rego": "package ccf_libs.net\n\nfetch(u) := http.send({\"method\": \"GET\", \"url\": u})\n\nhelper(x) := x\n", + "banner.rego": vendorBanner, + } + tests := []struct { + name string + modules map[string]string + wantErr string + }{ + {"parse error", map[string]string{"bad.rego": "package compliance_framework.bad\n\nviolation contains x if {"}, "bad.rego"}, + {"direct http.send is located", map[string]string{"x.rego": "package compliance_framework.x\n\nr := http.send({\"method\": \"GET\", \"url\": \"http://x\"})\n"}, "x.rego:3"}, + {"direct http.send", map[string]string{"x.rego": "package compliance_framework.x\n\nr := http.send({\"method\": \"GET\", \"url\": \"http://x\"})\n"}, "http.send"}, + {"vendor helper wrapping http.send", map[string]string{"x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\nr := net.fetch(\"http://x\")\n"}, "http.send"}, + {"with f as http.send", map[string]string{"x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\nr if {\n\tnet.helper(1) with net.helper as http.send\n}\n"}, "reachable from authored rule"}, + {"import from another policy path", map[string]string{"x.rego": "package compliance_framework.x\n\nimport data.other_bundle.lib\n\nr := lib.f(1)\n"}, "x.rego"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: tt.modules}) + errs := errorsOf(check(m, nil), agentconfig.SeverityError) + if len(errs) == 0 { + t.Fatal("expected an error") + } + joined := PolicyErrors(errs).Error() + if !strings.Contains(joined, tt.wantErr) { + t.Fatalf("error %q does not mention %q", joined, tt.wantErr) + } + }) + } + + t.Run("pure builtins are allowed", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\na := net.cidr_contains(\"10.0.0.0/8\", \"10.1.2.3\")\n\nb := rego.parse_module(\"x.rego\", \"package x\")\n\nc if trace(\"hi\")\n", + }}) + if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { + t.Fatalf("unexpected errors %v", errs) + } + }) + + t.Run("vendor-only denied builtins are not attributed to authored rules", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\nr := net.helper(1)\n", + }}) + if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { + t.Fatalf("unexpected errors %v", errs) + } + }) + + t.Run("manifest roots excluding the inline package", func(t *testing.T) { + m := materialize(t, map[string]string{ + ".manifest": `{"roots": ["compliance_framework/banner"]}`, + "banner.rego": vendorBanner, + }, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\nr := 1\n", + }}) + if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) == 0 { + t.Fatal("a package outside the manifest roots must be rejected") + } + }) +} + +func TestCheck_Tests(t *testing.T) { + vendor := map[string]string{ + "banner.rego": vendorBanner, + "banner_test.rego": "package compliance_framework.banner_test\n\nimport data.compliance_framework.banner\n\ntest_fails if { count(banner.violation) == 42 with input as {} }\n", + } + t.Run("failing vendor test warns", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{"x.rego": "package compliance_framework.x\n\nr := 1\n"}}) + res := check(m, nil) + if len(errorsOf(res, agentconfig.SeverityError)) != 0 || len(errorsOf(res, agentconfig.SeverityWarning)) != 1 { + t.Fatalf("expected exactly one warning, got %+v", res) + } + }) + t.Run("failing authored test rejects", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Delete: []string{"banner_test.rego"}, Modules: map[string]string{ + "x_test.rego": "package compliance_framework.x_test\n\ntest_bad if { 1 == 2 }\n", + }}) + if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 1 || errs[0].Path != "x_test.rego" || errs[0].Row == 0 { + t.Fatalf("expected one authored test error with a location, got %+v", errs) + } + }) + t.Run("policy_data is visible", func(t *testing.T) { + m := materialize(t, map[string]string{}, &agentconfig.PolicyBundle{ + Modules: map[string]string{"x_test.rego": "package compliance_framework.x_test\n\ntest_data if { data.limits.max == 5; data.limits.keep == true }\n"}, + Data: map[string]any{"limits": map[string]any{"keep": true}}, + }) + if res := check(m, map[string]any{"limits": map[string]any{"max": 5}}); len(res) != 0 { + t.Fatalf("expected the test to see bundle data and policy_data, got %+v", res) + } + }) + t.Run("timeout", func(t *testing.T) { + old := TestTimeout + TestTimeout = time.Second + t.Cleanup(func() { TestTimeout = old }) + m := materialize(t, map[string]string{}, &agentconfig.PolicyBundle{Modules: map[string]string{ + "x_test.rego": "package compliance_framework.x_test\n\ntest_slow if { count([x | some x in numbers.range(1, 100000000)]) > 0 }\n", + }}) + start := time.Now() + res := errorsOf(check(m, nil), agentconfig.SeverityError) + if len(res) == 0 || time.Since(start) > 20*time.Second { + t.Fatalf("expected a timeout error within bounds, got %+v after %s", res, time.Since(start)) + } + }) +} + +// TestMergePolicyDataParity checks that the test store sees the same data a plugin evaluates. +func TestMergePolicyDataParity(t *testing.T) { + m := materialize(t, map[string]string{}, &agentconfig.PolicyBundle{ + Modules: map[string]string{"x.rego": "package compliance_framework.x\n\nr := 1\n"}, + Data: map[string]any{"a": map[string]any{"x": 1, "y": 2}, "list": []any{1}}, + }) + policyData := map[string]any{"a": map[string]any{"y": 3, "z": map[string]any{"q": true}}, "list": []any{2}, "b": 5} + query, err := policyeval.NewFromBundlePath(m.Dir, policyData, policyeval.Options{}).PrepareForEval(context.Background(), rego.Query("x = data")) + if err != nil { + t.Fatal(err) + } + rs, err := query.Eval(context.Background()) + if err != nil || len(rs) != 1 { + t.Fatalf("eval: %v %v", rs, err) + } + got, _ := json.Marshal(rs[0].Bindings["x"].(map[string]any)) + var gotMap map[string]any + _ = json.Unmarshal(got, &gotMap) + delete(gotMap, "compliance_framework") + + var bundleData map[string]any + _ = json.Unmarshal([]byte(readFile(t, m.Dir, "data.json")), &bundleData) + want, _ := json.Marshal(mergePolicyData(bundleData, policyData)) + var wantMap map[string]any + _ = json.Unmarshal(want, &wantMap) + if !reflect.DeepEqual(gotMap, wantMap) { + t.Fatalf("parity: policyeval sees %v, mergePolicyData gives %v", gotMap, wantMap) + } +} + +func TestGC_KeepsActiveAndNewest(t *testing.T) { + root := t.TempDir() + var dirs []string + for i := 0; i < 8; i++ { + d := filepath.Join(root, "ssh", strings.Repeat(string(rune('a'+i)), 4)) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + mod := time.Now().Add(time.Duration(i) * time.Minute) + if err := os.Chtimes(d, mod, mod); err != nil { + t.Fatal(err) + } + dirs = append(dirs, d) + } + if err := os.MkdirAll(filepath.Join(root, "ssh", ".tmp-abc"), 0o755); err != nil { + t.Fatal(err) + } + keep := map[string]struct{}{dirs[0]: {}} // the oldest is active + if err := GC(root, keep, 5); err != nil { + t.Fatal(err) + } + for i, d := range dirs { + _, err := os.Stat(d) + exists := err == nil + want := i == 0 || i >= 3 // active + the 5 newest (3..7) + if exists != want { + t.Fatalf("dir %d exists=%v want %v", i, exists, want) + } + } + if _, err := os.Stat(filepath.Join(root, "ssh", ".tmp-abc")); !os.IsNotExist(err) { + t.Fatal("abandoned temp dirs must be removed") + } +} diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go new file mode 100644 index 0000000..ebc08a3 --- /dev/null +++ b/internal/inlinepolicy/materialize.go @@ -0,0 +1,394 @@ +// Package inlinepolicy materializes inline policy bundles (policy_bundles in the file or the +// remote overlay) into write-once directories that plugins load like any other policy path, +// and checks them the way plugins will evaluate them (HLD §3.5, R17–R21). +// +// The package is a leaf: it imports api/pkg/agentconfig, api/pkg/agentconfig/regocheck, +// api/pkg/policyeval, OPA v1 and the standard library, never cmd. +package inlinepolicy + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/open-policy-agent/opa/v1/ast" + "sigs.k8s.io/yaml" +) + +// Resolver returns the policy root directory of an OCI tag or a local path (the directory +// plugins would receive for it). +type Resolver func(ctx context.Context, source string) (dir string, err error) + +// ErrResolve wraps a failure to fetch a bundle's extends tree (reported as download-failed). +var ErrResolve = errors.New("resolve extends") + +// PolicyErrors is a list of policy problems with at least one error; Materialize returns it +// for bundle content problems (reported as rejected/policy-errors). +type PolicyErrors []agentconfig.PolicyError + +func (p PolicyErrors) Error() string { + parts := make([]string, 0, len(p)) + for _, e := range p { + loc := e.Path + if e.Row > 0 { + loc = fmt.Sprintf("%s:%d:%d", e.Path, e.Row, e.Col) + } + parts = append(parts, fmt.Sprintf("%s %s: %s", e.Bundle, loc, e.Message)) + } + return strings.Join(parts, "; ") +} + +// Materialized is one bundle written to disk. +type Materialized struct { + Name string + Dir string // // + Digest string // agentconfig.BundleTreeDigest(files) + // Extends describes the vendor tree the bundle extends (nil when standalone). + Extends *agentconfig.PolicyBundleExtendsReport + Files []agentconfig.PolicyFileReport + // Authored are the paths written from Modules (and data.json when Data is set). + Authored map[string]bool + AuthoredTests []string + Warnings []agentconfig.PolicyError // delete of a missing path, skipped symlink, stray data file +} + +// Materialize builds bundle name in the R17 order (extends tree, delete, modules, data), +// checks the data-file rule (R18) and writes the result write-once under root. +func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBundle, resolve Resolver) (*Materialized, error) { + if b == nil { + return nil, PolicyErrors{{Bundle: name, Message: "bundle has no definition", Severity: agentconfig.SeverityError}} + } + if !agentconfig.BundleNamePattern.MatchString(name) { + return nil, PolicyErrors{{Bundle: name, Message: "invalid bundle name", Severity: agentconfig.SeverityError}} + } + m := &Materialized{Name: name, Authored: map[string]bool{}} + var errs PolicyErrors + warn := func(p, format string, args ...any) { + m.Warnings = append(m.Warnings, agentconfig.PolicyError{Bundle: name, Path: p, Message: fmt.Sprintf(format, args...), Severity: agentconfig.SeverityWarning}) + } + fail := func(p, format string, args ...any) { + errs = append(errs, agentconfig.PolicyError{Bundle: name, Path: p, Message: fmt.Sprintf(format, args...), Severity: agentconfig.SeverityError}) + } + + // 1. Base tree. + files := map[string][]byte{} + if b.Extends != nil { + if resolve == nil { + return nil, fmt.Errorf("%w: no resolver", ErrResolve) + } + dir, err := resolve(ctx, *b.Extends) + if err != nil { + return nil, fmt.Errorf("%w %s: %v", ErrResolve, *b.Extends, err) + } + baseFiles, skipped, err := readTree(dir) + if err != nil { + return nil, fmt.Errorf("%w %s: %v", ErrResolve, *b.Extends, err) + } + for _, s := range skipped { + warn(s, "symlink in the extends tree skipped") + } + m.Extends = &agentconfig.PolicyBundleExtendsReport{ + Source: *b.Extends, + Digest: agentconfig.BundleTreeDigest(baseFiles), + Files: inventory(baseFiles), + } + files = baseFiles + } + + // 2. Delete. + for _, p := range b.Delete { + if err := agentconfig.ValidateModulePath(p); err != nil { + fail(p, "%s", err.Error()) + continue + } + if _, ok := files[p]; !ok { + warn(p, "delete: %s is not in the extends tree", p) + continue + } + delete(files, p) + } + + // 3. Modules. + for _, p := range sortedKeys(b.Modules) { + if err := checkRelPath(p); err != nil { + fail(p, "%s", err.Error()) + continue + } + files[p] = []byte(b.Modules[p]) + m.Authored[p] = true + if strings.HasSuffix(p, "_test.rego") { + m.AuthoredTests = append(m.AuthoredTests, p) + } + } + + // 4. Data: RFC 7396 merge patch onto the root data file, emitted as data.json. + if b.Data != nil { + for _, f := range agentconfig.DataFileNames { + if _, ok := b.Modules[f]; ok { + fail(f, "set either data or a root-level %s module, not both", f) + } + } + } + if b.Data != nil && len(errs) == 0 { + if err := mergeRootData(files, b.Data); err != nil { + fail("data.json", "%s", err.Error()) + } else { + m.Authored["data.json"] = true + } + } + + // 5. Data-name rule (R18): OPA only loads data.json/.yaml/.yml; any other data-like file is + // an error when authored and a warning when the vendor shipped it. + for _, p := range sortedKeys(files) { + ext := strings.ToLower(path.Ext(p)) + if ext != ".json" && ext != ".yaml" && ext != ".yml" { + continue + } + if slices.Contains(agentconfig.DataFileNames, path.Base(p)) { + continue + } + if m.Authored[p] { + fail(p, "only data.json, data.yaml or data.yml data files are loaded by OPA") + } else { + warn(p, "OPA ignores %s: only data.json, data.yaml or data.yml data files are loaded", p) + } + } + if len(errs) > 0 { + agentconfig.SortPolicyErrors(errs) + return nil, errs + } + + // 6. Write once. + m.Digest = agentconfig.BundleTreeDigest(files) + final := filepath.Join(root, name, strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix)) + if err := writeOnce(final, files); err != nil { + return nil, err + } + m.Dir = final + + // 7. Inventory. + m.Files = inventory(files) + slices.Sort(m.AuthoredTests) + return m, nil +} + +// checkRelPath applies ValidateModulePath and re-checks that the cleaned path stays under the +// policy root. +func checkRelPath(p string) error { + if err := agentconfig.ValidateModulePath(p); err != nil { + return err + } + clean := filepath.Clean(filepath.FromSlash(p)) + if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("module path %q escapes the policy root", p) + } + return nil +} + +// mergeRootData merge-patches data onto the root data file (data.json, else a converted +// data.yaml/data.yml) and writes the result as data.json, removing the YAML files. +func mergeRootData(files map[string][]byte, data map[string]any) error { + target := []byte("{}") + for _, name := range []string{"data.yaml", "data.yml"} { + if raw, ok := files[name]; ok { + converted, err := yaml.YAMLToJSON(raw) + if err != nil { + return fmt.Errorf("%s does not parse: %v", name, err) + } + merged, err := agentconfig.MergePatch(target, converted) + if err != nil { + return err + } + target = merged + delete(files, name) + } + } + if raw, ok := files["data.json"]; ok { + merged, err := agentconfig.MergePatch(target, raw) + if err != nil { + return fmt.Errorf("data.json does not parse: %v", err) + } + target = merged + } + patch, err := json.Marshal(data) + if err != nil { + return err + } + out, err := agentconfig.MergePatch(target, patch) + if err != nil { + return err + } + files["data.json"] = out + return nil +} + +// readTree reads the regular files under dir (paths relative, slash-separated). Symlinks are +// skipped and returned. +func readTree(dir string) (map[string][]byte, []string, error) { + files := map[string][]byte{} + var skipped []string + err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(dir, p) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if d.Type()&fs.ModeSymlink != 0 { + skipped = append(skipped, rel) + return nil + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + files[rel] = raw + return nil + }) + return files, skipped, err +} + +// Inventory digests and lists a policy directory (OCI or local sources) for the report. +func Inventory(dir string) (string, []agentconfig.PolicyFileReport, error) { + files, _, err := readTree(dir) + if err != nil { + return "", nil, err + } + return agentconfig.BundleTreeDigest(files), inventory(files), nil +} + +func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { + out := make([]agentconfig.PolicyFileReport, 0, len(files)) + for _, p := range sortedKeys(files) { + sum := sha256.Sum256(files[p]) + r := agentconfig.PolicyFileReport{Path: p, SHA256: hex.EncodeToString(sum[:])} + if strings.HasSuffix(p, ".rego") { + if mod, err := ast.ParseModuleWithOpts(p, string(files[p]), ast.ParserOptions{RegoVersion: ast.RegoV1}); err == nil && mod != nil && mod.Package != nil { + r.Package = strings.TrimPrefix(mod.Package.Path.String(), "data.") + } + } + out = append(out, r) + } + return out +} + +// writeOnce writes files into final unless it already exists: a temp dir (dirs 0755, files +// 0644) renamed into place. Losing a rename race reuses the winner's directory. +func writeOnce(final string, files map[string][]byte) error { + if info, err := os.Stat(final); err == nil && info.IsDir() { + return nil + } + parent := filepath.Dir(final) + if err := os.MkdirAll(parent, 0o755); err != nil { + return err + } + var rnd [6]byte + _, _ = rand.Read(rnd[:]) + tmp := filepath.Join(parent, ".tmp-"+hex.EncodeToString(rnd[:])) + if err := os.MkdirAll(tmp, 0o755); err != nil { + return err + } + cleanup := func() { _ = os.RemoveAll(tmp) } + for p, content := range files { + dst := filepath.Join(tmp, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + cleanup() + return err + } + if err := os.WriteFile(dst, content, 0o644); err != nil { + cleanup() + return err + } + } + if err := os.Rename(tmp, final); err != nil { + cleanup() + if info, statErr := os.Stat(final); statErr == nil && info.IsDir() { + return nil + } + return err + } + return nil +} + +// GC removes materialized directories under root except those in keep and the perBundle +// newest per bundle, plus abandoned temp directories. +func GC(root string, keep map[string]struct{}, perBundle int) error { + bundles, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var errs []error + for _, b := range bundles { + if !b.IsDir() { + continue + } + bundleDir := filepath.Join(root, b.Name()) + entries, err := os.ReadDir(bundleDir) + if err != nil { + errs = append(errs, err) + continue + } + type dirInfo struct { + path string + mod int64 + } + var dirs []dirInfo + for _, e := range entries { + p := filepath.Join(bundleDir, e.Name()) + if strings.HasPrefix(e.Name(), ".tmp-") { + errs = append(errs, os.RemoveAll(p)) + continue + } + if !e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + dirs = append(dirs, dirInfo{p, info.ModTime().UnixNano()}) + } + sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod > dirs[j].mod }) + kept := 0 + for _, d := range dirs { + if _, ok := keep[d.path]; ok { + continue + } + if kept < perBundle { + kept++ + continue + } + errs = append(errs, os.RemoveAll(d.path)) + } + } + return errors.Join(errs...) +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} From 2f0e3a1d9cae5995dddd563edb18dbd8dda2aac0 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:36:35 -0300 Subject: [PATCH 06/47] docs: remote configuration, inline policy bundles and state (G4) - configuration.md: remote_config with R29 defaults, the modes and the apply_safe classification table, set locally only (R30); enabled; policy_bundles (extends/delete/modules/data, root-relative paths, data-file names, no cross-bundle imports); ${env:} in plugins.*.config only; env-sourced masking; tolerated bad schedules; weak file typing vs string-only overlays; the viper case/dot caveat; state dir and ID. - running_as_a_service.md: WorkingDirectory/StateDirectory, persistent state, CCF_STATE_DIR for containers, CCF_INSTANCE_ID for jobs. - ADR 0003: declared vs runtime forms, prepare-then-cancel, the agent as Classify authority, per-path compile unit and transitive denied builtins (with the residual policy_data risk), plugin env filter, opaque ETag, the R34 origin rule. - README: pointer. --- README.md | 11 ++ docs/adr/0003-remote-config-overlay.md | 83 ++++++++++++ docs/configuration.md | 174 ++++++++++++++++++++++++- docs/running_as_a_service.md | 15 ++- 4 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0003-remote-config-overlay.md diff --git a/README.md b/README.md index 5af85be..d9a19b0 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,17 @@ 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. +Plugins never receive `CCF_API_AUTH_*` variables. + +### Remote configuration, inline policies and state + +With `api.auth` credentials the agent reports its configuration to the API and can apply a configuration overlay +stored there (`remote_config`), including inline policy bundles (`policy_bundles`) and `${env:NAME}` placeholders in +plugin config. 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/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md new file mode 100644 index 0000000..5aa783b --- /dev/null +++ b/docs/adr/0003-remote-config-overlay.md @@ -0,0 +1,83 @@ +# 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, temporary inline policies) 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` and `api/pkg/agentconfig/regocheck`), 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, inline bundle materialization and +checks, and every download (`Prefetch`). Only then is the running configuration cancelled. Any failure leaves the +running configuration untouched and is reported. In-flight plugin runs drain for up to 5 minutes on a swap. A run that +fails on its own after a swap falls back to the previous configuration. Startup tries the fetched overlay, then the +cached applied overlay, then the file alone; only an unusable file exits. + +### 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. + +### Per-path compile unit and transitive denied builtins + +Plugins evaluate every policy path as its own bundle, so the agent checks an inline bundle per (plugin, policy path) +through the same `policyeval.NewFromBundlePath` prepare path `policy-manager` uses (R21). Cross-bundle imports are +unsupported. The API's Rego check is parse-level; the agent additionally walks every rule reachable from the bundle's +authored rules in the compiled rule graph and rejects any `policyeval.DeniedBuiltins` reference (`http.send`, +`net.lookup_ip_addr`, `opa.runtime`), including through vendor helpers and `with ... as http.send` (R19, R20). + +Residual risk (R20): a vendor rule that already calls `http.send` with a URL taken from `data` makes `policy_data` +edits to that plugin effectively able to direct its requests. Eval-time capabilities are a follow-up. + +### 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. + +### 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; every other file-origin error stays fatal +(startup exit 1, or last-known-good on reload). + +## 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 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..6d1458f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -154,7 +154,179 @@ 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 +and policy bundle names 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. A placeholder anywhere +else (for example `policy_data` or `labels`) is an error. `CCF_API_AUTH_*` may never be referenced. An unset variable +fails the configuration 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, as are keys that look like secrets (`secret`, `token`, `password`, `key`, `credential`, +`auth`) (R25). + +## 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). 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. + +## Policy bundles + +`policy_bundles` define policy paths inline, in the file or in a remote overlay. A plugin uses a bundle by listing +`inline:` in its `policies`: + +```yaml +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-local-ssh:v1 + policies: + - inline:ssh-hardening + policy_data: + max_auth_tries: 3 + +policy_bundles: + ssh-hardening: + extends: ghcr.io/compliance-framework/plugin-local-ssh-policies:v1 # optional: OCI tag or local path + delete: + - legacy_ciphers.rego # remove a vendor module (needs extends) + modules: # add or override modules + max_auth_tries.rego: | + package compliance_framework.max_auth_tries + + import rego.v1 + + violation contains {"remarks": "too many"} if input.max_auth_tries > data.max_auth_tries + data: # merge-patched into data.json + allowed_ciphers: ["aes256-gcm@openssh.com"] +``` + +- **Order (R17).** The `extends` tree is copied, then `delete` removes vendor files, then `modules` add or override + files, then `data` is merged (RFC 7396) onto the root `data.json` (a root `data.yaml` is converted) and written as + `data.json`. +- **Paths (R18)** are relative to the policy root the plugin receives, e.g. `max_auth_tries.rego`, not + `policies/max_auth_tries.rego`. `..`, absolute paths and empty segments are rejected. Symlinks in a local `extends` + tree are skipped. +- **Data files (R18).** OPA only loads `data.json`, `data.yaml` and `data.yml`. Any other `.json`/`.yaml`/`.yml` + module is an error (a warning when it comes from the vendor tree). Setting both `data` and a root `data.json` + module is an error. +- **Remote overlays.** An overlay `modules."": null` removes the effective module: an inherited vendor module shows + through again, and a module only the file defined is dropped. Omitting the key keeps the file's value. +- **One compile unit per policy path (R21).** Plugins load each policy path as a separate bundle, so a bundle cannot + import packages from another policy path; cross-bundle imports are unsupported. Put shared helpers in the bundle (or + its `extends` tree). +- **Checks.** Before a bundle is used the agent compiles it exactly as the plugin will, rejects any use of + `http.send`, `net.lookup_ip_addr` or `opa.runtime` reachable from the bundle's own rules (including through vendor + helpers and `with ... as http.send`), and runs its Rego tests with the plugin's `policy_data`. A failing test that the + bundle authored rejects the configuration; a failing vendor test is only a warning. +- Inline bundles are written under the state directory (`/inline///`) and are never downloaded. + +## 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: apply_safe # 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_inline_policies: true + allow_local_sources: false +``` + +Defaults (R29): `mode` is `apply_safe` when `api.auth` is set and `off` otherwise (no credentials always forces +`off`); `poll_interval` is `60s`; `trusted_sources` and `overridable_config_flags` are empty; `allow_inline_policies` +is `true`; `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, a policy entry or a bundle | safe | +| a plugin source or policy entry already used by the file (or by a bundle's `extends`) | 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) | +| an inline bundle change or `inline:` policy entry | safe while `allow_inline_policies` is true, else 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, policy checks) 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`, +`policy-errors`, `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. + +## 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`), the remote +configuration cache and materialized inline bundles. 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/running_as_a_service.md b/docs/running_as_a_service.md index 1dc7bfa..eb88331 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, the remote configuration cache and inline +policy bundles). 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 From b9c1cadccf6f594fd28db1b4f8e2f2889236bd8a Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:40:16 -0300 Subject: [PATCH 07/47] chore: pin compliance-framework/api to PR #465 head 9a512a6 api #465 merged api main (#464 evaluation artifacts), so the pinned module now carries both pkg/agentconfig and the SDK Artifact client the agent already uses. The tree builds and tests without a go.work. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8070e69..4283699 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.19.1-0.20260930152307-aa005f7646ab + github.com/compliance-framework/api v0.19.1-0.20260930153743-9a512a6f0176 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 diff --git a/go.sum b/go.sum index ca37e8d..b5b1e1d 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.19.1-0.20260930152307-aa005f7646ab h1:b+BmYw1eOD8OkILydtiRhu2Ip71tqMn/fVsjxHLa5lo= -github.com/compliance-framework/api v0.19.1-0.20260930152307-aa005f7646ab/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.19.1-0.20260930153743-9a512a6f0176 h1:uUoZ1T0+B6nxbvj/NL6Q2Cm6QRU6Cu8JYtPOIST84Eo= +github.com/compliance-framework/api v0.19.1-0.20260930153743-9a512a6f0176/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= From e785e4cbaf23ffe3b421e196e610b864e5fee4c8 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:04:04 -0300 Subject: [PATCH 08/47] fix(inlinepolicy): never execute a denied builtin during checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check ran the bundle's Rego tests even after the transitive walk had found a denied builtin reachable from authored code, so an authored _test.rego calling a vendor helper that wraps http.send made the agent issue the request before rejecting the revision (D17, HLD §8). Check now returns before the tests when the walk reports anything, and the tests themselves run sandboxed: modules are compiled under policyeval.SandboxCapabilities with every denied builtin ref rewritten to a stub that always errors. Vendor modules that only reference a denied builtin off the authored path still compile; their tests fail as warnings. --- internal/inlinepolicy/check.go | 66 ++++++++++++++++++++-- internal/inlinepolicy/inlinepolicy_test.go | 53 +++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/internal/inlinepolicy/check.go b/internal/inlinepolicy/check.go index b4433c9..3253cfb 100644 --- a/internal/inlinepolicy/check.go +++ b/internal/inlinepolicy/check.go @@ -38,6 +38,8 @@ type CheckInput struct { // 2. denied builtins (R19, R20): any policyeval.DeniedBuiltins ref reachable from an authored // rule through the compiled rule graph — including `with f as http.send` — is an error; // 3. tests: a failing authored _test.rego test is an error, a failing vendor test a warning. +// Tests never run when step 2 found a denied builtin, and they run sandboxed: a denied +// builtin can never execute on the agent host (D17, HLD §8). // // The parse-level checks (regocheck) run once per bundle before materialization. func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { @@ -81,10 +83,16 @@ func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { return out } - // 2. Transitive denied builtins. - for _, h := range deniedReachable(compiler, in.PolicyDir, in.Authored) { + // 2. Transitive denied builtins. The revision is rejected, so the tests (which would + // execute the denied builtin) never run. + hits := deniedReachable(compiler, in.PolicyDir, in.Authored) + for _, h := range hits { add(agentconfig.SeverityError, h.loc, "forbidden builtin %s is reachable from authored rule %s", h.name, h.from) } + if len(hits) > 0 { + agentconfig.SortPolicyErrors(out) + return out + } // 3. Tests. out = append(out, runTests(ctx, in, b.Data, modules)...) @@ -201,10 +209,12 @@ func runTests(ctx context.Context, in CheckInput, bundleData map[string]any, mod testCtx, cancel := context.WithTimeout(ctx, TestTimeout) defer cancel() store := inmem.NewFromObject(mergePolicyData(bundleData, in.PolicyData)) + sandboxed, stubs, caps := sandboxTestModules(modules) ch, err := tester.NewRunner(). - SetCompiler(ast.NewCompiler()). + SetCompiler(ast.NewCompiler().WithCapabilities(caps)). + AddCustomBuiltins(stubs). SetStore(store). - SetModules(modules). + SetModules(sandboxed). SetTimeout(TestTimeout). RunTests(testCtx, nil) if err != nil { @@ -235,6 +245,54 @@ func runTests(ctx context.Context, in CheckInput, bundleData map[string]any, mod return out } +// deniedStubPrefix names the stand-ins for denied builtins in sandboxed test runs. +const deniedStubPrefix = "ccf_denied_builtin." + +// sandboxTestModules returns copies of modules in which every denied builtin ref (a call, +// `with ... as `, any position) is rewritten to a stub builtin that always errors, +// the stubs, and capabilities without the denied builtins (policyeval.SandboxCapabilities) +// plus the stubs. A denied builtin therefore can never execute during tests: whatever the +// rewrite misses fails to compile instead. Vendor modules that merely reference a denied +// builtin off the authored path still compile, and their tests fail (as warnings). +func sandboxTestModules(modules map[string]*ast.Module) (map[string]*ast.Module, []*tester.Builtin, *ast.Capabilities) { + caps := policyeval.SandboxCapabilities() + stubNames := map[string]ast.Ref{} + var stubs []*tester.Builtin + for _, name := range policyeval.DeniedBuiltins { + decl, ok := ast.BuiltinMap[name] + if !ok { + continue + } + stubName := deniedStubPrefix + strings.ReplaceAll(name, ".", "_") + stubDecl := &ast.Builtin{Name: stubName, Decl: decl.Decl} + caps.Builtins = append(caps.Builtins, stubDecl) + stubNames[name] = ast.MustParseRef(stubName) + denied := name + stubs = append(stubs, &tester.Builtin{ + Decl: stubDecl, + Func: rego.FunctionDyn(®o.Function{Name: stubName, Decl: decl.Decl}, func(rego.BuiltinContext, []*ast.Term) (*ast.Term, error) { + return nil, fmt.Errorf("%s is not available in inline policy tests", denied) + }), + }) + } + + out := make(map[string]*ast.Module, len(modules)) + for path, mod := range modules { + cp := mod.Copy() + res, err := ast.TransformRefs(cp, func(ref ast.Ref) (ast.Value, error) { + if stub, ok := stubNames[ref.String()]; ok { + return stub.Copy(), nil + } + return ref, nil + }) + if m, ok := res.(*ast.Module); ok && err == nil { + cp = m + } + out[path] = cp + } + return out, stubs, caps +} + func prefixPlugin(plugin, msg string) string { if plugin == "" { return msg diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go index c3d3c46..cbcee3a 100644 --- a/internal/inlinepolicy/inlinepolicy_test.go +++ b/internal/inlinepolicy/inlinepolicy_test.go @@ -4,11 +4,14 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" "os" "path/filepath" "reflect" "runtime" "strings" + "sync/atomic" "testing" "time" @@ -298,6 +301,56 @@ func TestCheck_CompileAndBuiltins(t *testing.T) { }) } +// TestCheck_DeniedBuiltinNeverExecutes is the D17/§8 regression: a denied builtin reachable +// from authored Rego (or called by a vendor test) must never run on the agent host, not even +// while the revision is being rejected. +func TestCheck_DeniedBuiltinNeverExecutes(t *testing.T) { + var hits atomic.Int32 + probe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer probe.Close() + + vendor := map[string]string{ + "lib/net.rego": "package ccf_libs.net\n\nfetch(u) := http.send({\"method\": \"GET\", \"url\": u})\n", + "banner.rego": vendorBanner, + } + + t.Run("authored test through a vendor helper is rejected before tests run", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ + "x_test.rego": "package compliance_framework.x_test\n\nimport data.ccf_libs.net\n\ntest_x if { net.fetch(\"" + probe.URL + "/exfil?d=secret\") }\n", + }}) + errs := errorsOf(check(m, nil), agentconfig.SeverityError) + if len(errs) == 0 || !strings.Contains(PolicyErrors(errs).Error(), "forbidden builtin http.send") { + t.Fatalf("expected the forbidden builtin error, got %+v", errs) + } + }) + + t.Run("vendor test calling http.send is sandboxed", func(t *testing.T) { + withTest := map[string]string{ + "lib/net_test.rego": "package ccf_libs.net_test\n\nimport data.ccf_libs.net\n\ntest_fetch if { net.fetch(\"" + probe.URL + "/vendor\") }\n\ntest_direct if { http.send({\"method\": \"GET\", \"url\": \"" + probe.URL + "/direct\"}) }\n", + } + for k, v := range vendor { + withTest[k] = v + } + m := materialize(t, withTest, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\nr := 1\n", + }}) + res := check(m, nil) + if errs := errorsOf(res, agentconfig.SeverityError); len(errs) != 0 { + t.Fatalf("vendor tests must only warn, got errors %+v", errs) + } + if warns := errorsOf(res, agentconfig.SeverityWarning); len(warns) != 2 { + t.Fatalf("expected both vendor tests to fail as warnings, got %+v", res) + } + }) + + if n := hits.Load(); n != 0 { + t.Fatalf("the probe server received %d request(s); a denied builtin executed during Check", n) + } +} + func TestCheck_Tests(t *testing.T) { vendor := map[string]string{ "banner.rego": vendorBanner, From 4493064eb62c04a5750b3b1627a224640b810c0c Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:07:12 -0300 Subject: [PATCH 09/47] fix(config): keep file configs that load on main loading (R34, R51, R60) The shared Validate made some file values fatal that main accepted: - verbosity: -1 (hclog Warn, a quieter agent); - a literal ${env:X} in plugins.*.labels / policy_data (an opaque string); - an unset ${env:X} in plugins.*.config (main passed the literal through). File-origin negative verbosity and env-location errors outside policy_bundles are now warn-only: reported in warnings, value unchanged, no plugin skip. Per the owner's R60 decision, an unset variable the file references (per pointer and variable) is a warning and the literal reaches the plugin unchanged; an unset variable an overlay introduces still fails with failed/env-missing. Overlay-origin validation stays strict. Files without policy_bundles no longer go through the YAML->JSON decode, so YAML that JSON cannot represent (.nan, .inf) loads as on main. --- cmd/config.go | 132 +++++++++++++++++++++++-- cmd/config_test.go | 92 +++++++++++++++++ cmd/reconciler.go | 15 ++- cmd/remote_test.go | 52 +++++++++- docs/adr/0003-remote-config-overlay.md | 15 ++- docs/configuration.md | 19 ++-- 6 files changed, 299 insertions(+), 26 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index ef8e631..80c3c29 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -65,6 +66,25 @@ func isToleratedFileRule(e agentconfig.FieldError) bool { 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. +// +// policy_bundles is a new feature, so its env-location errors stay fatal. +func isWarnOnlyFileRule(e agentconfig.FieldError) bool { + switch { + case e.Path == "/verbosity": + return true + case e.Code == agentconfig.FieldCodeEnvLocation: + segs := agentconfig.SplitPointer(e.Path) + return len(segs) == 0 || segs[0] != "policy_bundles" + } + return false +} + // 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) { @@ -270,14 +290,20 @@ func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte, ext string) ( if err != nil { return nil, err } - bundles, err := decodePolicyBundles(raw, ext) - if err != nil { - return nil, err - } - if bundles == nil && v.IsSet("policy_bundles") { - return nil, fmt.Errorf("policy_bundles is only supported in yaml, json and toml config files") + // Only a file that sets policy_bundles takes the non-viper decode, so every other file + // loads exactly as on main (e.g. a YAML .nan, which JSON cannot represent). + if v.IsSet("policy_bundles") { + switch ext { + case "yaml", "yml", "json", "toml": + default: + return nil, fmt.Errorf("policy_bundles is only supported in yaml, json and toml config files") + } + bundles, err := decodePolicyBundles(raw, ext) + if err != nil { + return nil, err + } + declared.PolicyBundles = bundles } - declared.PolicyBundles = bundles base := &baseSnapshot{ declared: declared, @@ -298,14 +324,15 @@ func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte, ext string) ( 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: reported - skip map[string]string // plugin name -> reason, for tolerated errors + 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), the rest is fatal. +// (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 { @@ -328,6 +355,8 @@ func partitionByOrigin(err error, overlayTouched []string) validationPartition { } out.skip[segs[1]] = e.Message } + case isWarnOnlyFileRule(e): + out.warnings = append(out.warnings, e) default: out.fatal = append(out.fatal, e) } @@ -348,6 +377,89 @@ func touchedByOverlay(ptr string, touched []string) bool { 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 sortedPluginNames(declared.Plugins) { + p := declared.Plugins[name] + if p == nil { + continue + } + for _, key := range sortedStringKeys(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 "" +} + +func sortedStringKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} + // 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. diff --git a/cmd/config_test.go b/cmd/config_test.go index 1b000c1..d2ace22 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/hashicorp/go-hclog" "google.golang.org/protobuf/proto" ) @@ -209,6 +210,97 @@ plugins: } } +// 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, nil, 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) + } + }) + t.Run("policy_bundles env-location stays fatal", func(t *testing.T) { + errs := agentconfig.ValidationErrors{{Path: "/policy_bundles/b/modules/x.rego", Code: agentconfig.FieldCodeEnvLocation, Message: "env"}} + if p := partitionByOrigin(errs, nil); len(p.fatal) != 1 { + t.Fatalf("policy_bundles is a new feature and stays strict, got %#v", p) + } + }) +} + +// TestLoadBase_NoPolicyBundlesTakesMainPath: without policy_bundles the file never goes through +// the JSON conversion, so YAML that JSON cannot represent loads as on main. +func TestLoadBase_NoPolicyBundlesTakesMainPath(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.PolicyBundles != nil { + t.Fatalf("no bundles expected, got %v", base.declared.PolicyBundles) + } + base = mustLoadBase(t, "yaml", "api:\n url: http://localhost:8080\npolicy_bundles:\nplugins:\n ssh:\n source: ./plugin-ssh\n") + if base.declared.PolicyBundles != nil { + t.Fatalf("a null policy_bundles must load as none, got %v", base.declared.PolicyBundles) + } +} + func TestLoadBase_MissingAPIURLIsFatal(t *testing.T) { _, err := loadBase(AgentCmd(), writeConfigFile(t, "yaml", ` api: diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 2854ff8..6b1ae22 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -484,7 +484,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent return nil, rejected(agentconfig.ReasonInvalidConfig, errs) } - resolved, err := agentconfig.ResolveEnv(declared, rc.lookupEnv) + resolved, envWarnings, err := resolveEnv(declared, base.declared, rc.lookupEnv) switch { case errors.Is(err, agentconfig.ErrEnvForbidden): return nil, rejected(agentconfig.ReasonForbiddenChanges, err) @@ -493,6 +493,11 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent 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}) + } + } inline, aerr := rc.prepareInline(ctx, resolved, part.skip) if aerr != nil { @@ -525,7 +530,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent digest: digest, identity: candidateIdentity(declared, inline.dirs), bundles: inline.reports, - warnings: part.warnings, + warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), policyWarnings: inline.warnings, }, nil } @@ -874,7 +879,11 @@ func (rc *reconciler) handleRemoteError(op string, err error, backoff *time.Time func (rc *reconciler) logWarnings(warnings []agentconfig.FieldError) { for _, w := range warnings { - rc.logger.Warn("Ignoring a problem in the config file; the plugin is skipped", "path", w.Path, "error", w.Message) + 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) } } diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 54b26d3..d0f30b7 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -709,14 +709,22 @@ func TestStartupLadder(t *testing.T) { } func TestEnvPlaceholders(t *testing.T) { - // ${env:} is only resolved in plugins.*.config (R24): in the file's policy_data it is an - // error (agentconfig env-location), and an overlay using it there is rejected. - bad := newRemoteHarness(t, remoteConfig("apply_all", "")+` + // ${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}" `) - if _, err := bad.rc.startup(context.Background()); err == nil || !strings.Contains(err.Error(), "only resolved in plugins.*.config") { - t.Fatalf("expected an env-location error for policy_data in the file, got %v", err) + 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", "")) @@ -758,6 +766,40 @@ func TestEnvPlaceholders(t *testing.T) { } } +// 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 * * * *"}}}`) diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 5aa783b..ba52de6 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -71,8 +71,19 @@ The shared `Validate` is stricter than the agent used to be in one way: it parse 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; every other file-origin error stays fatal -(startup exit 1, or last-known-good on reload). +`/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` (except in `policy_bundles`, a new feature). 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 diff --git a/docs/configuration.md b/docs/configuration.md index 6d1458f..d41ad42 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -192,10 +192,14 @@ plugins: dsn: "postgres://app:${env:PG_PASSWORD}@db:5432/app" ``` -Placeholders are resolved **only** in `plugins.*.config`, in the file and in a remote overlay. A placeholder anywhere -else (for example `policy_data` or `labels`) is an error. `CCF_API_AUTH_*` may never be referenced. An unset variable -fails the configuration 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). +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, as are keys that look like secrets (`secret`, `token`, `password`, `key`, `credential`, @@ -204,8 +208,11 @@ Plugin values set through viper environment variables (`CCF_PLUGINS_

_CONFIG_< ## 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). 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. +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. ## Policy bundles From f8c7c336077159445fa5252bbc5539b1aa6c96d7 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:18:01 -0300 Subject: [PATCH 10/47] fix(inlinepolicy): follow a symlinked extends root, reject an empty tree WalkDir does not follow a symlinked root, so a local extends pointing at a symlink (a versioned directory, a ConfigMap mount) materialized without its vendor policies and only warned. readTree now resolves the root with EvalSymlinks (in-tree symlinks are still skipped), and an extends tree with no .rego file fails with ErrResolve (download-failed, retried with backoff) instead of silently dropping every vendor policy. --- docs/configuration.md | 9 ++++--- internal/inlinepolicy/inlinepolicy_test.go | 28 ++++++++++++++++++++++ internal/inlinepolicy/materialize.go | 15 +++++++++--- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d41ad42..d639b47 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -248,8 +248,8 @@ policy_bundles: files, then `data` is merged (RFC 7396) onto the root `data.json` (a root `data.yaml` is converted) and written as `data.json`. - **Paths (R18)** are relative to the policy root the plugin receives, e.g. `max_auth_tries.rego`, not - `policies/max_auth_tries.rego`. `..`, absolute paths and empty segments are rejected. Symlinks in a local `extends` - tree are skipped. + `policies/max_auth_tries.rego`. `..`, absolute paths and empty segments are rejected. Symlinks inside a local + `extends` tree are skipped. - **Data files (R18).** OPA only loads `data.json`, `data.yaml` and `data.yml`. Any other `.json`/`.yaml`/`.yml` module is an error (a warning when it comes from the vendor tree). Setting both `data` and a root `data.json` module is an error. @@ -261,7 +261,10 @@ policy_bundles: - **Checks.** Before a bundle is used the agent compiles it exactly as the plugin will, rejects any use of `http.send`, `net.lookup_ip_addr` or `opa.runtime` reachable from the bundle's own rules (including through vendor helpers and `with ... as http.send`), and runs its Rego tests with the plugin's `policy_data`. A failing test that the - bundle authored rejects the configuration; a failing vendor test is only a warning. + bundle authored rejects the configuration; a failing vendor test is only a warning. Tests never run when a forbidden + builtin is reachable, and they run sandboxed: a test that calls one of those builtins fails instead of executing it. +- **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any + `.rego` file fails with `download-failed`. - Inline bundles are written under the state directory (`/inline///`) and are never downloaded. ## Remote configuration diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go index cbcee3a..928077b 100644 --- a/internal/inlinepolicy/inlinepolicy_test.go +++ b/internal/inlinepolicy/inlinepolicy_test.go @@ -191,6 +191,34 @@ func TestMaterialize_Errors(t *testing.T) { }) } +func TestMaterialize_ExtendsRootSymlinkAndEmptyTree(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks") + } + t.Run("symlinked root is followed", func(t *testing.T) { + vendorDir, _ := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "lib/x.rego": "package lib.x\n"}) + link := filepath.Join(t.TempDir(), "policies") + if err := os.Symlink(vendorDir, link); err != nil { + t.Fatal(err) + } + resolve := func(context.Context, string) (string, error) { return link, nil } + m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("/etc/ccf/policies")}, resolve) + if err != nil { + t.Fatal(err) + } + if len(m.Extends.Files) != 2 || len(m.Warnings) != 0 { + t.Fatalf("expected both vendor files through the symlinked root, got %+v (warnings %+v)", m.Extends.Files, m.Warnings) + } + }) + t.Run("a tree without .rego files is an error", func(t *testing.T) { + _, resolve := vendorTree(t, map[string]string{"README.md": "nothing here"}) + _, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) + if !errors.Is(err, ErrResolve) || !strings.Contains(err.Error(), "no .rego files") { + t.Fatalf("expected an ErrResolve for an empty extends tree, got %v", err) + } + }) +} + func TestMaterialize_SkipsSymlinksInExtends(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlinks") diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index ebc08a3..72c7d27 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -96,6 +96,10 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu if err != nil { return nil, fmt.Errorf("%w %s: %v", ErrResolve, *b.Extends, err) } + if !slices.ContainsFunc(sortedKeys(baseFiles), func(p string) bool { return strings.HasSuffix(p, ".rego") }) { + // An empty or unreadable vendor tree would silently drop every vendor policy. + return nil, fmt.Errorf("%w %s: the policy tree at %s has no .rego files", ErrResolve, *b.Extends, dir) + } for _, s := range skipped { warn(s, "symlink in the extends tree skipped") } @@ -234,12 +238,17 @@ func mergeRootData(files map[string][]byte, data map[string]any) error { return nil } -// readTree reads the regular files under dir (paths relative, slash-separated). Symlinks are -// skipped and returned. +// readTree reads the regular files under dir (paths relative, slash-separated). dir itself +// may be a symlink (e.g. /etc/ccf/policies -> a versioned directory); symlinks inside the +// tree are skipped and returned. func readTree(dir string) (map[string][]byte, []string, error) { files := map[string][]byte{} var skipped []string - err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + dir, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, nil, err + } + err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } From 0c1a8c7af6a765ad8b6f26bc34e96a815a53f80c Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:18:28 -0300 Subject: [PATCH 11/47] fix(reconciler): address review of the reload and startup paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A revision (or file edit) whose effective config equals the running one is adopted in place: the running runtime keeps going, its sync metadata (now atomic) gets the new applied revision, and the reconciler's record gets the new base/overlay. Before, it was never recorded active, so every poll re-prepared it and heartbeat/report kept the stale applied-revision. - reconcile returns early when the remaining target is remembered-rejected for the current base (last-known-good), instead of re-preparing it (and re-running inline policy checks) on every poll (§5.4). - The rejected memory and failed backoff key on the ETag, or on revision + sha256(overlay) when a response had no ETag. - run() binds the fallback before notifying onRunFailed; file-only candidates that fail to prepare or to run enter the failed backoff, and a successful prepare of the backed-off target keeps growing the delay, so a bad file edit no longer flaps every poll. - A startup download failure of the file-only config calls AgentRunner.ReportStartupFailure (marks the plugins, sends the startup-failure agent evidence) before exiting 1, as Run did on main. - A SIGINT/SIGTERM during the 5-minute reload drain is no longer lost: it switches to the 30s shutdown path and exits (R33). - Each prepare network step (Prefetch, extends, report inventory) is bounded by prepareNetworkTimeout (5m) and maps to failed/download-failed. - Inline GC also runs after every swap. Cron jobs capture config, client and logger once per setup. Race tests assert the final config and stop rc.run. --- cmd/agent.go | 166 +++++++++++++++++++--- cmd/inline.go | 50 +++++-- cmd/inline_test.go | 24 ++++ cmd/reconciler.go | 158 ++++++++++++++++++--- cmd/reconciler_test.go | 178 +++++++++++++++++++++-- cmd/remote_test.go | 187 ++++++++++++++++++++++++- docs/adr/0003-remote-config-overlay.md | 19 ++- internal/agentstate/cache.go | 9 +- 8 files changed, 714 insertions(+), 77 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index 92dcea7..68f1d83 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "math/rand" @@ -20,6 +21,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -80,7 +82,9 @@ type agentEvidenceConfig struct { // 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. +// 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"` @@ -91,7 +95,8 @@ type agentConfig struct { // inlinePolicyDirs maps "inline:" policy entries to their materialized directory. inlinePolicyDirs map[string]string // sync is what the heartbeat reports about the applied remote configuration (R11, R45). - sync syncMeta + // Read it with syncInfo; nil means the zero syncMeta. + sync *atomic.Pointer[syncMeta] // remote is the normalized remote_config block. remote agentconfig.RemoteConfig } @@ -103,6 +108,25 @@ type syncMeta struct { Mode string // remote_config.mode } +// 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 p := ac.sync.Load(); p != nil { + return *p + } + return syncMeta{} +} + +// 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) +} + // 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 @@ -171,7 +195,10 @@ const CCFPropNamespace = "https://compliance-framework.github.io/ns" // configRevisionPropName stamps evidence with the applied remote configuration revision (R38). const configRevisionPropName = "agent-config-revision" -const daemonCronStopTimeout = 30 * time.Second + +// 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. @@ -338,6 +365,7 @@ func agentRunner(cmd *cobra.Command, args []string) error { rc.resolvePolicy = func(ctx context.Context, source string) (string, error) { return ar.downloadPolicy(ctx, source, logger) } + rc.onStartupFailure = ar.ReportStartupFailure active, err := rc.startup(context.Background()) if err != nil { @@ -407,6 +435,10 @@ type AgentRunner struct { 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 @@ -1049,7 +1081,7 @@ func (ar *AgentRunner) resolveProtocolsFor(ctx context.Context, config *agentCon 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) @@ -1094,7 +1126,7 @@ func (ar *AgentRunner) runDaemon(ctx context.Context) error { 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") @@ -1102,7 +1134,19 @@ func (ar *AgentRunner) runDaemon(ctx context.Context) error { agentCronStopCtx := agentCron.Stop() heartbeatCronStopCtx := heartbeatCron.Stop() agentEvidenceCronStopCtx := agentEvidenceCron.Stop() - if !waitForCronStop(reloadDrainTimeout, agentCronStopCtx, heartbeatCronStopCtx, agentEvidenceCronStopCtx) { + 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") @@ -1111,7 +1155,30 @@ func (ar *AgentRunner) runDaemon(ctx context.Context) error { } } +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() @@ -1139,13 +1206,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,7 +1309,12 @@ 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 } @@ -1433,15 +1507,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), @@ -1549,13 +1631,14 @@ func (ar *AgentRunner) SendHeartbeat(ctx context.Context, staticAgentUUID uuid.U // 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 { - if config == nil || config.sync.AppliedRevision <= 0 { + meta := config.syncInfo() + if meta.AppliedRevision <= 0 { return nil } return []sdktypes.Property{{ Ns: CCFPropNamespace, Name: configRevisionPropName, - Value: strconv.FormatInt(config.sync.AppliedRevision, 10), + Value: strconv.FormatInt(meta.AppliedRevision, 10), }} } @@ -1564,10 +1647,10 @@ func configRevisionProps(config *agentConfig) []sdktypes.Property { // 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 config != nil && config.sync.Mode != "" && config.sync.Mode != agentconfig.ModeOff { - rev := config.sync.AppliedRevision + if meta := config.syncInfo(); meta.Mode != "" && meta.Mode != agentconfig.ModeOff { + rev := meta.AppliedRevision hb.ConfigRevision = &rev - hb.ConfigDigest = config.sync.Digest + hb.ConfigDigest = meta.Digest } return hb } @@ -1963,18 +2046,55 @@ func (ar *AgentRunner) Prefetch(ctx context.Context, cfg *agentConfig) error { } for _, source := range sortedSetKeys(pluginSources) { if _, err := ar.download(ctx, source, AgentPluginDir, "plugin", platformDownloadKey(platform), logger, remote.WithPlatform(platform)); err != nil { - return fmt.Errorf("download plugin %s: %w", source, err) + return &downloadError{source: source, err: err} } } ar.resolveProtocolsFor(ctx, cfg, logger) for _, source := range sortedSetKeys(policySources) { if _, err := ar.downloadPolicy(ctx, source, logger); err != nil { - return fmt.Errorf("download policy %s: %w", source, err) + 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) + } +} + // 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 { diff --git a/cmd/inline.go b/cmd/inline.go index 2fa4da3..e780b4e 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -59,7 +59,7 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co materialized := map[string]*inlinepolicy.Materialized{} var problems []agentconfig.PolicyError for _, name := range sortedBoolKeys(refs) { - m, err := inlinepolicy.Materialize(ctx, rc.inlineRoot(), name, resolved.PolicyBundles[name], rc.resolvePolicy) + m, err := inlinepolicy.Materialize(ctx, rc.inlineRoot(), name, resolved.PolicyBundles[name], rc.boundedResolver()) var perrs inlinepolicy.PolicyErrors switch { case errors.As(err, &perrs): @@ -139,7 +139,8 @@ func policyRejection(errs []agentconfig.PolicyError) *applyError { // sourceReports inventories the non-inline policy paths of runtime for the report. OCI trees // are memoized per (source, dir); local trees are re-read. func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) []agentconfig.PolicyBundleReport { - if rc.resolvePolicy == nil { + resolve := rc.boundedResolver() + if resolve == nil { return nil } sources := map[string]struct{}{} @@ -152,7 +153,7 @@ func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) [ } var out []agentconfig.PolicyBundleReport for _, source := range sortedSetKeys(sources) { - dir, err := rc.resolvePolicy(ctx, source) + dir, err := resolve(ctx, source) if err != nil { continue } @@ -174,17 +175,46 @@ func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) [ return out } -// afterStartup removes materialized inline bundles that are neither active nor among the -// newest per bundle. It runs only at the end of startup. +// boundedResolver wraps resolvePolicy with prepareNetworkTimeout per call (nil when unset). +func (rc *reconciler) boundedResolver() inlinepolicy.Resolver { + if rc.resolvePolicy == nil { + return nil + } + return func(ctx context.Context, source string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, prepareNetworkTimeout) + defer cancel() + return rc.resolvePolicy(ctx, source) + } +} + +// afterStartup removes the materialized inline bundles startup does not use. func (rc *reconciler) afterStartup(active *candidate) { - if active == nil || !rc.store.Writable() { + rc.gcInline(active) +} + +// gcInline removes materialized inline bundles that none of keep uses and that are not among +// the newest inlineGCKeepPerBundle per bundle. It runs after startup and after every swap +// (keep = the running, the replaced and the new pending candidate), so a long-running daemon +// does not accumulate one directory per revision. +func (rc *reconciler) gcInline(keep ...*candidate) { + if !rc.store.Writable() { return } - keep := map[string]struct{}{} - for _, dir := range active.runtime.inlinePolicyDirs { - keep[dir] = struct{}{} + dirs := map[string]struct{}{} + found := false + for _, c := range keep { + if c == nil || c.runtime == nil { + continue + } + found = true + for _, dir := range c.runtime.inlinePolicyDirs { + dirs[dir] = struct{}{} + } + } + if !found { + return } - if err := inlinepolicy.GC(rc.inlineRoot(), keep, inlineGCKeepPerBundle); err != nil { + if err := inlinepolicy.GC(rc.inlineRoot(), dirs, inlineGCKeepPerBundle); err != nil { rc.logger.Warn("Could not clean up old inline policy bundles", "error", err) } } diff --git a/cmd/inline_test.go b/cmd/inline_test.go index a9e73dc..7814261 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "fmt" "os" "path/filepath" "strings" @@ -122,3 +123,26 @@ func TestInline_DownloadPoliciesNeverDownloadsInline(t *testing.T) { t.Fatalf("policy location = %q", got) } } + +// TestInline_GCAfterSwap bounds the materialized directories of a long-running daemon: GC runs +// after every swap, not only at startup. +func TestInline_GCAfterSwap(t *testing.T) { + h, _ := newInlineHarness(t) + h.remote.publish(0, `{}`) + mustStartup(t, h.rc) + for rev := int64(1); rev <= 10; rev++ { + overlay := fmt.Sprintf(`{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\n# rev %d\nviolation contains {\"remarks\": \"x\"} if input.max > data.max\n"}}}}`, rev) + h.remote.publish(rev, overlay) + if got := h.poll(t); got.overlay == nil || got.overlay.Revision != rev { + r := h.remote.lastReport(t) + t.Fatalf("revision %d did not apply: %s/%s %s", rev, r.Status, r.Reason, derefString(r.Error)) + } + } + entries, err := os.ReadDir(filepath.Join(h.rc.inlineRoot(), "ssh")) + if err != nil { + t.Fatal(err) + } + if len(entries) > inlineGCKeepPerBundle+2 { + t.Fatalf("expected at most %d materialized dirs after GC, found %d", inlineGCKeepPerBundle+2, len(entries)) + } +} diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 6b1ae22..75cc864 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -39,6 +39,10 @@ var ( // failedRetryMin / failedRetryMax bound the retry of a failed/* revision. failedRetryMin = time.Minute failedRetryMax = 10 * time.Minute + // prepareNetworkTimeout bounds each network step of prepare (plugin/policy prefetch, an + // extends tree, a report inventory), 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 @@ -75,6 +79,9 @@ type applyError struct { Err error Unsafe []agentconfig.Change PolicyErrors []agentconfig.PolicyError + // 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 { @@ -166,6 +173,9 @@ type reconciler struct { 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 @@ -198,7 +208,7 @@ type reconciler struct { reportBackoffUntil time.Time loggedOnce map[string]bool - failedETag string + failedKey string // overlayKey of the target in failed backoff failedBase string failedRetryAt time.Time failedInterval time.Duration @@ -337,6 +347,9 @@ func (rc *reconciler) startup(ctx context.Context) (*candidate, error) { } 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) @@ -387,14 +400,41 @@ func sameOverlay(a, b *agentstate.OverlayRecord) bool { } } +// 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 - return r != nil && rec != nil && r.ETag == rec.ETag && r.BaseFingerprint == rc.base.fingerprint + 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) } -// recordFailure remembers a rejected revision for (etag, base), or starts the failed backoff. +// 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 { @@ -405,6 +445,7 @@ func (rc *reconciler) recordFailure(target *agentstate.OverlayRecord, aerr *appl 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, @@ -413,26 +454,35 @@ func (rc *reconciler) recordFailure(target *agentstate.OverlayRecord, aerr *appl rc.saveCache() return } - rc.startFailedBackoff(target) + rc.startFailedBackoff(target, rc.base.fingerprint) } -func (rc *reconciler) startFailedBackoff(target *agentstate.OverlayRecord) { - if rc.failedETag == target.ETag && rc.failedBase == rc.base.fingerprint && rc.failedInterval > 0 { +// 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.failedETag, rc.failedBase = target.ETag, rc.base.fingerprint + rc.failedKey, rc.failedBase = key, baseFingerprint rc.failedRetryAt = rc.now().Add(rc.failedInterval) } func (rc *reconciler) inFailedBackoff(target *agentstate.OverlayRecord) bool { - return target != nil && rc.failedInterval > 0 && rc.failedETag == target.ETag && + return rc.failedInterval > 0 && rc.failedKey == overlayKey(target) && rc.failedBase == rc.base.fingerprint && rc.now().Before(rc.failedRetryAt) } -func (rc *reconciler) clearFailedBackoff() { - rc.failedETag, rc.failedBase, rc.failedInterval = "", "", 0 +// 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 @@ -508,8 +558,13 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent if err != nil { return nil, failed(agentconfig.ReasonInvalidConfig, err) } - if err := rc.runner.Prefetch(ctx, runtime); err != nil { - return nil, failed(agentconfig.ReasonDownloadFailed, 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 } if rcfg.Mode != agentconfig.ModeOff { inline.reports = append(inline.reports, rc.sourceReports(ctx, runtime)...) @@ -518,10 +573,11 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent // 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()...) - runtime.sync = syncMeta{Digest: digest, Mode: rcfg.Mode} + meta := syncMeta{Digest: digest, Mode: rcfg.Mode} if ov != nil { - runtime.sync.AppliedRevision = ov.Revision + meta.AppliedRevision = ov.Revision } + runtime.setSync(meta) return &candidate{ base: base, overlay: ov, @@ -620,6 +676,45 @@ func (rc *reconciler) bind(active *candidate, cancel context.CancelFunc) { } } +// 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 +} + +// running returns the candidate the run loop has bound (it may still be draining after a swap). +func (rc *reconciler) running() *candidate { + rc.mu.Lock() + defer rc.mu.Unlock() + return rc.active +} + // swap makes next the pending candidate and cancels the running one. func (rc *reconciler) swap(next *candidate) { rc.mu.Lock() @@ -651,17 +746,24 @@ func (rc *reconciler) current() *candidate { // 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 *candidate + 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) - rc.notifyRunFailed(active) + failedRun = active active, previous = previous, nil continue } @@ -727,8 +829,16 @@ func (rc *reconciler) loop(ctx context.Context) { // 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 { - rc.startFailedBackoff(c.overlay) if sameOverlay(rc.cache.Applied, c.overlay) { rc.cache.Applied = nil if prev := rc.current(); prev != nil && prev.overlay != nil { @@ -770,6 +880,13 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { 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 (and re-running inline policy checks) 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 @@ -783,19 +900,20 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { rc.maybeReport(ctx, active, aerr) return } - rc.clearFailedBackoff() + rc.clearFailedBackoff(target) if isApplyMode(rcfg.Mode) { rc.cache.Applied = target rc.saveCache() } rc.lastOutcome = nil if active != nil && active.identity == cand.identity { - rc.logger.Debug("Trigger did not change the effective configuration") - rc.maybeReport(ctx, active, nil) + 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), nil) return } rc.logger.Info("Applying the new configuration", "revision", revisionForLog(target)) rc.swap(cand) + rc.gcInline(rc.running(), active, cand) rc.maybeReport(ctx, cand, nil) } diff --git a/cmd/reconciler_test.go b/cmd/reconciler_test.go index c1e1a28..d89ddc0 100644 --- a/cmd/reconciler_test.go +++ b/cmd/reconciler_test.go @@ -3,11 +3,15 @@ package cmd import ( "context" "errors" + "io" + "net/http" "os" "path/filepath" + "slices" "strings" "sync" "sync/atomic" + "syscall" "testing" "time" @@ -16,20 +20,35 @@ import ( "github.com/google/uuid" ) -// fakePrefetcher records Prefetch calls and can be told to fail. +// 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(_ context.Context, _ *agentConfig) error { +func (f *fakePrefetcher) Prefetch(ctx context.Context, _ *agentConfig) error { f.mu.Lock() - defer f.mu.Unlock() f.calls++ - return f.err + 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 @@ -220,31 +239,110 @@ func TestReconciler_RapidFileEventsRace(t *testing.T) { defer cancel() go rc.loop(ctx) - var writes atomic.Int32 + const last = "*/9 * * * *" // unique: no intermediate reload can satisfy the check for i := 0; i < 50; i++ { schedule := "*/5 * * * *" - if i%2 == 0 { + 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) } - writes.Add(1) rc.signalFile() time.Sleep(time.Millisecond) } - // The last write wins. + // The last write wins: it is started, and it is still what runs once reloads quiesce. deadline := time.After(5 * time.Second) - for { + for applied := false; !applied; { select { case cfg := <-rec.started: - if *cfg.Plugins["ssh"].Schedule == "*/5 * * * *" { - return - } + 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) { @@ -373,3 +471,59 @@ func TestRunDaemonDrainsInFlightRunsOnReload(t *testing.T) { 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 index d0f30b7..41309d7 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "context" "encoding/json" "errors" @@ -17,6 +18,7 @@ import ( "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. @@ -835,6 +837,7 @@ func TestReconciler_RaceInterleavedFileEventsAndPolls(t *testing.T) { 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 { @@ -843,13 +846,29 @@ func TestReconciler_RaceInterleavedFileEventsAndPolls(t *testing.T) { maxRunning = max(maxRunning, running) last = cfg mu.Unlock() - <-ctx.Done() - mu.Lock() - running-- - mu.Unlock() - return nil + 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() @@ -880,3 +899,161 @@ func TestReconciler_RaceInterleavedFileEventsAndPolls(t *testing.T) { 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/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index ba52de6..ed1599a 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -30,9 +30,16 @@ The file is decoded through viper's weak decoder exactly as before (R51). Only a 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, inline bundle materialization and checks, and every download (`Prefetch`). Only then is the running configuration cancelled. Any failure leaves the -running configuration untouched and is reported. In-flight plugin runs drain for up to 5 minutes on a swap. A run that -fails on its own after a swap falls back to the previous configuration. Startup tries the fetched overlay, then the -cached applied overlay, then the file alone; only an unusable file exits. +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. +Materialized inline bundles are garbage-collected at startup and after every swap. 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). ### The agent is the Classify authority @@ -48,6 +55,9 @@ through the same `policyeval.NewFromBundlePath` prepare path `policy-manager` us unsupported. The API's Rego check is parse-level; the agent additionally walks every rule reachable from the bundle's authored rules in the compiled rule graph and rejects any `policyeval.DeniedBuiltins` reference (`http.send`, `net.lookup_ip_addr`, `opa.runtime`), including through vendor helpers and `with ... as http.send` (R19, R20). +When the walk finds one, the bundle's Rego tests are not run. The tests themselves run sandboxed: they compile under +`policyeval.SandboxCapabilities` with every denied builtin rewritten to a stub that errors, so no denied builtin ever +executes on the agent host while a revision is checked (D17); a vendor test that needs one simply fails (a warning). Residual risk (R20): a vendor rule that already calls `http.send` with a URL taken from `data` makes `policy_data` edits to that plugin effectively able to direct its requests. Eval-time capabilities are a follow-up. @@ -63,7 +73,8 @@ environment minus `CCF_API_AUTH_*`, so plugins never see the agent's API credent 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. +(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. ### File-origin tolerance (R34) diff --git a/internal/agentstate/cache.go b/internal/agentstate/cache.go index 8c47846..33380de 100644 --- a/internal/agentstate/cache.go +++ b/internal/agentstate/cache.go @@ -40,10 +40,13 @@ type OverlayRecord struct { // 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. +// 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"` + 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"` From 66af7bfd434f57578f0b262f194f82312b72c08a Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:32:58 -0300 Subject: [PATCH 12/47] chore: pin compliance-framework/api to PR #465 head 6c801a3 Round 2 of api#465: artifact-digest on the policy bundle report entries and the policy contract check (policyeval.CheckContract / ValidateResult) wired into regocheck and Execute. Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4283699..b5e7137 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.19.1-0.20260930153743-9a512a6f0176 + github.com/compliance-framework/api v0.19.1-0.20261001101400-6c801a34ca14 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 diff --git a/go.sum b/go.sum index b5b1e1d..7eeed11 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.19.1-0.20260930153743-9a512a6f0176 h1:uUoZ1T0+B6nxbvj/NL6Q2Cm6QRU6Cu8JYtPOIST84Eo= -github.com/compliance-framework/api v0.19.1-0.20260930153743-9a512a6f0176/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.19.1-0.20261001101400-6c801a34ca14 h1:Ni4ZWSmfCwKx4I3onC02JrVnRB3twoURmKL9r3wtrVw= +github.com/compliance-framework/api v0.19.1-0.20261001101400-6c801a34ca14/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= From 7077ef623f374710197db22a70a255cec47e0e8d Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:55:14 -0300 Subject: [PATCH 13/47] feat(runner): one archiver and a process-wide artifact uploader (R62) The configuration report is about to upload the policy trees it names as artifacts, the same artifacts evidence references for playback. For both to land on the same artifact, and for a tree to be uploaded once, they need one archiver and one uploader. - internal/policytree: ReadTree (resolves a symlinked root, skips inner symlinks, as inline materialization and OPA do) and TarFiles (sorted, fixed headers), TarDirectory = both. A symlinked policy root, which the old tarDirectory refused, now archives like the directory it points to. - runner.ArtifactUploader: the per-digest cache, retries and old-API backoff, now exported, keyed per API and safe to share. Endpoint binds it to one API; NewApiHelper takes it with WithArtifactUploader (and keeps a private one without it). - WithPolicyPaths resolves each path when the helper is created, so the bundle artifact is the tree the run started with even if the path is a symlink swapped afterwards (inline stable paths, R67). Co-Authored-By: Claude Opus 5.5 --- docs/policy_artifacts.md | 10 +- internal/policytree/policytree.go | 93 ++++++++++ internal/policytree/policytree_test.go | 97 ++++++++++ runner/policy_artifacts.go | 233 +++++++++++++------------ runner/policy_artifacts_test.go | 71 +++++++- runner/result.go | 44 ++++- 6 files changed, 426 insertions(+), 122 deletions(-) create mode 100644 internal/policytree/policytree.go create mode 100644 internal/policytree/policytree_test.go diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 3ff904c..82e7827 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -27,8 +27,14 @@ The API does all canonicalisation and hashing; see its `docs/artifacts.md`. digests, before the next message arrives. The raw data is never forwarded with the evidence, and the agent never holds the whole batch in memory. -The agent only reads bundles at the policy paths it gave that plugin. It remembers what it -has already uploaded, so unchanged content is not uploaded again on later runs. +The agent only reads bundles at the policy paths it gave that plugin, resolved when the +run starts (an inline bundle's stable path is a symlink the agent may point elsewhere +later), so the bundle artifact is the tree the run evaluated. Symlinks inside a bundle are +skipped, as OPA skips them. The agent remembers what it has already uploaded to each API, +so unchanged content is not uploaded again on later runs. The same process-wide uploader +serves the configuration report, which uploads the policy trees it names (see +`configuration.md`, "Sources for the UI"); a tree uploaded there is not uploaded again for +evidence, and both produce the same artifact digest. ## Plugins diff --git a/internal/policytree/policytree.go b/internal/policytree/policytree.go new file mode 100644 index 0000000..e57e3c1 --- /dev/null +++ b/internal/policytree/policytree.go @@ -0,0 +1,93 @@ +// Package policytree reads policy trees from disk and archives them. It is the one place +// that decides which files a policy tree has, so every consumer sees the same tree: inline +// bundle materialization, the report inventory, and the policy bundle artifacts uploaded at +// configuration time (the reconciler) and at evaluation time (the plugins' API helper). +// Uploading the same directory from either place therefore produces the same bytes, and the +// API assigns the same artifact digest (R62). +// +// The package is a leaf: it imports only the standard library. +package policytree + +import ( + "archive/tar" + "bytes" + "io/fs" + "os" + "path/filepath" + "slices" +) + +// ReadTree reads the regular files under dir, keyed by their slash-separated path relative +// to dir. dir itself may be a symlink (for example /etc/ccf/policies -> a versioned +// directory, or an inline bundle's stable path); symlinks inside the tree are skipped and +// returned, as OPA's bundle loader skips them too. Other non-regular files are ignored. +func ReadTree(dir string) (files map[string][]byte, skipped []string, err error) { + files = map[string][]byte{} + root, err := filepath.EvalSymlinks(dir) + if err != nil { + return nil, nil, err + } + err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if d.Type()&fs.ModeSymlink != 0 { + skipped = append(skipped, rel) + return nil + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + files[rel] = raw + return nil + }) + if err != nil { + return nil, nil, err + } + return files, skipped, nil +} + +// TarFiles archives files as regular entries in path order, with a fixed mode and no +// times, so the same file map always produces the same bytes. (The API canonicalizes the +// archive anyway; determinism here makes the local upload cache hit.) +func TarFiles(files map[string][]byte) ([]byte, error) { + paths := make([]string, 0, len(files)) + for p := range files { + paths = append(paths, p) + } + slices.Sort(paths) + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, p := range paths { + content := files[p] + if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: p, Mode: 0o644, Size: int64(len(content))}); err != nil { + return nil, err + } + if _, err := tw.Write(content); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// TarDirectory is ReadTree followed by TarFiles. +func TarDirectory(dir string) ([]byte, error) { + files, _, err := ReadTree(dir) + if err != nil { + return nil, err + } + return TarFiles(files) +} diff --git a/internal/policytree/policytree_test.go b/internal/policytree/policytree_test.go new file mode 100644 index 0000000..7d4cb41 --- /dev/null +++ b/internal/policytree/policytree_test.go @@ -0,0 +1,97 @@ +package policytree + +import ( + "archive/tar" + "bytes" + "io" + "os" + "path/filepath" + "runtime" + "testing" +) + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestReadTree_FollowsRootSymlinkSkipsInner(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + base := t.TempDir() + tree := filepath.Join(base, "v2") + write(t, filepath.Join(tree, "a.rego"), "a") + write(t, filepath.Join(tree, "sub", "b.rego"), "b") + write(t, filepath.Join(base, "outside.rego"), "secret") + if err := os.Symlink(filepath.Join(base, "outside.rego"), filepath.Join(tree, "link.rego")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("v2", filepath.Join(base, "current")); err != nil { + t.Fatal(err) + } + + files, skipped, err := ReadTree(filepath.Join(base, "current")) + if err != nil { + t.Fatal(err) + } + if len(files) != 2 || string(files["a.rego"]) != "a" || string(files["sub/b.rego"]) != "b" { + t.Fatalf("files = %v", files) + } + if len(skipped) != 1 || skipped[0] != "link.rego" { + t.Fatalf("skipped = %v", skipped) + } + + // The symlinked root and the directory itself archive to the same bytes. + viaLink, err := TarDirectory(filepath.Join(base, "current")) + if err != nil { + t.Fatal(err) + } + direct, err := TarDirectory(tree) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(viaLink, direct) { + t.Fatal("a symlinked root must archive like the directory it points to") + } +} + +func TestTarFiles_DeterministicAndSorted(t *testing.T) { + files := map[string][]byte{"z.rego": []byte("z"), "a/b.rego": []byte("b"), "m.json": []byte("{}")} + first, err := TarFiles(files) + if err != nil { + t.Fatal(err) + } + for range 5 { + again, err := TarFiles(map[string][]byte{"m.json": []byte("{}"), "z.rego": []byte("z"), "a/b.rego": []byte("b")}) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, again) { + t.Fatal("the same file map must archive to the same bytes") + } + } + tr := tar.NewReader(bytes.NewReader(first)) + var names []string + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + if h.Typeflag != tar.TypeReg || h.Mode != 0o644 || !h.ModTime.IsZero() && h.ModTime.Unix() != 0 { + t.Fatalf("unexpected header %+v", h) + } + names = append(names, h.Name) + } + if len(names) != 3 || names[0] != "a/b.rego" || names[1] != "m.json" || names[2] != "z.rego" { + t.Fatalf("entries = %v", names) + } +} diff --git a/runner/policy_artifacts.go b/runner/policy_artifacts.go index bfeb9d3..0fc4990 100644 --- a/runner/policy_artifacts.go +++ b/runner/policy_artifacts.go @@ -1,20 +1,17 @@ package runner import ( - "archive/tar" - "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" - "io/fs" "net/http" - "os" "path/filepath" "sync" "time" + "github.com/compliance-framework/agent/internal/policytree" "github.com/compliance-framework/agent/runner/proto" "github.com/compliance-framework/api/sdk" "github.com/compliance-framework/api/sdk/types" @@ -25,6 +22,10 @@ import ( // them once per evaluation, sends the evidence with the digests the API returns, and never // forwards the raw data. The API canonicalises and hashes; the agent only hashes locally to // avoid re-uploading content it already sent. +// +// The same artifact store is the agent's one channel for policy sources (R62): the +// reconciler uploads the policy trees it reports, through the same ArtifactUploader, so a +// tree uploaded at configuration time is not uploaded again at evaluation time. const ( artifactUploadAttempts = 3 @@ -34,107 +35,99 @@ const ( artifactsUnsupportedRecheck = 10 * time.Minute ) -// errArtifactsUnsupported means the API predates artifact storage. -var errArtifactsUnsupported = errors.New("the API does not support policy artifacts") +// ErrArtifactsUnsupported means the API predates artifact storage. +var ErrArtifactsUnsupported = errors.New("the API does not support policy artifacts") -type artifactUploader struct { - client *sdk.Client - policyPaths map[string]struct{} - retryDelay time.Duration +// ArtifactClient is the artifact route of one API (sdk.Client.Artifact). +type ArtifactClient interface { + Upload(ctx context.Context, mediaType string, content []byte) (*sdk.ArtifactInfo, error) +} + +// ArtifactUploader uploads artifacts for the whole agent process: the reconciler and the API +// helper of every plugin run share one, so content is uploaded once per API whoever needs +// it first. It remembers what each API already has (by a local hash of the bytes), retries +// temporary failures, and backs off from an API that predates artifact storage. It is safe +// for concurrent use. +type ArtifactUploader struct { + retryDelay time.Duration + now func() time.Time mu sync.Mutex - uploaded map[[sha256.Size]byte]string // local hash of uploaded bytes -> API digest - unsupportedUntil time.Time - now func() time.Time + uploaded map[artifactKey]string // what each API already has -> its digest + unsupportedUntil map[string]time.Time // per API } -func newArtifactUploader(client *sdk.Client) *artifactUploader { - return &artifactUploader{ - client: client, - policyPaths: map[string]struct{}{}, - retryDelay: 250 * time.Millisecond, - uploaded: map[[sha256.Size]byte]string{}, - now: time.Now, - } +type artifactKey struct { + api string + local [sha256.Size]byte } -// evaluationKey identifies an evaluation: by its stream Id when it has one, otherwise by -// what it depended on, since evidence from one evaluation sent in a batch arrives over gRPC -// as separate copies. -func evaluationKey(e *proto.PolicyEvaluation) string { - if id := e.GetId(); id != "" { - return "id:" + id - } - h := sha256.New() - for _, part := range [][]byte{[]byte(e.GetPolicyPath()), e.GetInput(), e.GetPolicyData()} { - _, _ = fmt.Fprintf(h, "%d:", len(part)) - _, _ = h.Write(part) +// NewArtifactUploader returns an empty uploader. +func NewArtifactUploader() *ArtifactUploader { + return &ArtifactUploader{ + retryDelay: 250 * time.Millisecond, + now: time.Now, + uploaded: map[artifactKey]string{}, + unsupportedUntil: map[string]time.Time{}, } - return "content:" + hex.EncodeToString(h.Sum(nil)) } -func (u *artifactUploader) storeEvaluation(ctx context.Context, evaluation *proto.PolicyEvaluation) (*types.PolicyArtifacts, error) { - u.mu.Lock() - unsupported := u.now().Before(u.unsupportedUntil) - u.mu.Unlock() - if unsupported { - return nil, errArtifactsUnsupported - } - - policyPath := filepath.Clean(evaluation.GetPolicyPath()) - if _, ok := u.policyPaths[policyPath]; !ok { - return nil, fmt.Errorf("policy path %q is not one of the plugin's policy bundles", evaluation.GetPolicyPath()) - } - if len(evaluation.GetInput()) == 0 { - return nil, errors.New("the evaluation has no input data") - } +// Endpoint binds the uploader to one API: api identifies it (its base URL) and client is its +// artifact route. Endpoints of the same api share what was uploaded. +func (u *ArtifactUploader) Endpoint(api string, client ArtifactClient) *ArtifactEndpoint { + return &ArtifactEndpoint{u: u, api: api, client: client} +} - bundle, err := tarDirectory(policyPath) - if err != nil { - return nil, fmt.Errorf("package policy bundle: %w", err) - } +// ArtifactEndpoint uploads to one API through a shared ArtifactUploader. +type ArtifactEndpoint struct { + u *ArtifactUploader + api string + client ArtifactClient +} - refs := &types.PolicyArtifacts{} - if refs.BundleDigest, err = u.upload(ctx, sdk.ArtifactMediaTypePolicyBundle, bundle); err != nil { - return nil, fmt.Errorf("upload policy bundle: %w", err) - } - if refs.InputDigest, err = u.upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetInput()); err != nil { - return nil, fmt.Errorf("upload input data: %w", err) - } - if len(evaluation.GetPolicyData()) > 0 { - if refs.PolicyDataDigest, err = u.upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetPolicyData()); err != nil { - return nil, fmt.Errorf("upload policy data: %w", err) - } - } - return refs, nil +// unsupported reports whether the API was found not to support artifacts recently. +func (e *ArtifactEndpoint) unsupported() bool { + e.u.mu.Lock() + defer e.u.mu.Unlock() + return e.u.now().Before(e.u.unsupportedUntil[e.api]) } -// upload stores content unless this agent already uploaded the same bytes, retrying -// temporary failures, and returns the digest the API assigned. -func (u *artifactUploader) upload(ctx context.Context, mediaType string, content []byte) (string, error) { - local := sha256.Sum256(append([]byte(mediaType+"\x00"), content...)) +// Upload stores content unless this agent already uploaded the same bytes to this API, +// retrying temporary failures, and returns the digest the API assigned. A 404 or 405 means +// the API predates artifacts: ErrArtifactsUnsupported, and no upload to it is attempted for +// a while. Other failures are returned as they are (*sdk.ArtifactStatusError for a status). +func (e *ArtifactEndpoint) Upload(ctx context.Context, mediaType string, content []byte) (string, error) { + u := e.u + key := artifactKey{api: e.api, local: sha256.Sum256(append([]byte(mediaType+"\x00"), content...))} u.mu.Lock() - digest, ok := u.uploaded[local] + digest, ok := u.uploaded[key] + unsupported := u.now().Before(u.unsupportedUntil[e.api]) u.mu.Unlock() if ok { return digest, nil } + if unsupported { + return "", ErrArtifactsUnsupported + } + if e.client == nil { + return "", errors.New("no API client") + } var err error for attempt := 1; attempt <= artifactUploadAttempts; attempt++ { var info *sdk.ArtifactInfo - info, err = u.client.Artifact.Upload(ctx, mediaType, content) + info, err = e.client.Upload(ctx, mediaType, content) if err == nil { - u.remember(local, info.Digest) + u.remember(key, info.Digest) return info.Digest, nil } var statusErr *sdk.ArtifactStatusError if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) { u.mu.Lock() - u.unsupportedUntil = u.now().Add(artifactsUnsupportedRecheck) + u.unsupportedUntil[e.api] = u.now().Add(artifactsUnsupportedRecheck) u.mu.Unlock() - return "", errArtifactsUnsupported + return "", ErrArtifactsUnsupported } if !retryable(ctx, err) || attempt == artifactUploadAttempts { break @@ -148,13 +141,64 @@ func (u *artifactUploader) upload(ctx context.Context, mediaType string, content return "", err } -func (u *artifactUploader) remember(local [sha256.Size]byte, digest string) { +func (u *ArtifactUploader) remember(key artifactKey, digest string) { u.mu.Lock() defer u.mu.Unlock() if len(u.uploaded) >= artifactCacheLimit { - u.uploaded = map[[sha256.Size]byte]string{} + u.uploaded = map[artifactKey]string{} } - u.uploaded[local] = digest + u.uploaded[key] = digest +} + +// evaluationKey identifies an evaluation: by its stream Id when it has one, otherwise by +// what it depended on, since evidence from one evaluation sent in a batch arrives over gRPC +// as separate copies. +func evaluationKey(e *proto.PolicyEvaluation) string { + if id := e.GetId(); id != "" { + return "id:" + id + } + h := sha256.New() + for _, part := range [][]byte{[]byte(e.GetPolicyPath()), e.GetInput(), e.GetPolicyData()} { + _, _ = fmt.Fprintf(h, "%d:", len(part)) + _, _ = h.Write(part) + } + return "content:" + hex.EncodeToString(h.Sum(nil)) +} + +// storeEvaluation uploads what one evaluation used. The bundle is read from the directory +// the plugin's policy path resolved to when the helper was created (see WithPolicyPaths), so +// it is the tree the run evaluated even if the path is a symlink swapped afterwards. +func (h *apiHelper) storeEvaluation(ctx context.Context, evaluation *proto.PolicyEvaluation) (*types.PolicyArtifacts, error) { + if h.artifacts.unsupported() { + return nil, ErrArtifactsUnsupported + } + policyPath := filepath.Clean(evaluation.GetPolicyPath()) + dir, ok := h.policyPaths[policyPath] + if !ok { + return nil, fmt.Errorf("policy path %q is not one of the plugin's policy bundles", evaluation.GetPolicyPath()) + } + if len(evaluation.GetInput()) == 0 { + return nil, errors.New("the evaluation has no input data") + } + + bundle, err := policytree.TarDirectory(dir) + if err != nil { + return nil, fmt.Errorf("package policy bundle: %w", err) + } + + refs := &types.PolicyArtifacts{} + if refs.BundleDigest, err = h.artifacts.Upload(ctx, sdk.ArtifactMediaTypePolicyBundle, bundle); err != nil { + return nil, fmt.Errorf("upload policy bundle: %w", err) + } + if refs.InputDigest, err = h.artifacts.Upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetInput()); err != nil { + return nil, fmt.Errorf("upload input data: %w", err) + } + if len(evaluation.GetPolicyData()) > 0 { + if refs.PolicyDataDigest, err = h.artifacts.Upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetPolicyData()); err != nil { + return nil, fmt.Errorf("upload policy data: %w", err) + } + } + return refs, nil } // retryable reports whether an upload failure may succeed if repeated: server errors, rate @@ -169,38 +213,3 @@ func retryable(ctx context.Context, err error) bool { } return true } - -// tarDirectory archives the regular files under dir. The API canonicalises the archive, -// so file order, modes and times here do not affect the digest. -func tarDirectory(dir string) ([]byte, error) { - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - if !d.Type().IsRegular() { - return fmt.Errorf("%s is not a regular file", path) - } - rel, err := filepath.Rel(dir, path) - if err != nil { - return err - } - content, err := os.ReadFile(path) - if err != nil { - return err - } - if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: filepath.ToSlash(rel), Mode: 0o644, Size: int64(len(content))}); err != nil { - return err - } - _, err = tw.Write(content) - return err - }) - if err != nil { - return nil, err - } - if err := tw.Close(); err != nil { - return nil, err - } - return buf.Bytes(), nil -} diff --git a/runner/policy_artifacts_test.go b/runner/policy_artifacts_test.go index 99bd534..21b83b4 100644 --- a/runner/policy_artifacts_test.go +++ b/runner/policy_artifacts_test.go @@ -12,11 +12,13 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "sync" "testing" "time" + "github.com/compliance-framework/agent/internal/policytree" policyManager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/agent/runner/proto" "github.com/compliance-framework/api/sdk" @@ -76,7 +78,7 @@ func newTestHelper(t *testing.T, api *fakeAPI, policyPaths ...string) *apiHelper t.Cleanup(server.Close) client := sdk.NewClient(server.Client(), &sdk.Config{BaseURL: server.URL}) helper := NewApiHelper(hclog.NewNullLogger(), client, map[string]string{"_agent": "test"}, "test-plugin", WithPolicyPaths(policyPaths)) - helper.artifacts.retryDelay = time.Millisecond + helper.uploader.retryDelay = time.Millisecond return helper } @@ -162,7 +164,7 @@ func TestCreateEvidenceUnderAnOldAPISendsEvidenceWithoutArtifacts(t *testing.T) assert.Len(t, api.uploads, 1) // ...but checks again later, so an upgraded API is picked up. - helper.artifacts.now = func() time.Time { return time.Now().Add(artifactsUnsupportedRecheck + time.Minute) } + helper.uploader.now = func() time.Time { return time.Now().Add(artifactsUnsupportedRecheck + time.Minute) } require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ evidenceFor("three", &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), })) @@ -352,3 +354,68 @@ violation contains {"id": "wget-version"} if input.wget != data.allowed_versions assert.NotEmpty(t, refs["policy-data-digest"]) assert.True(t, strings.HasPrefix(refs["bundle-digest"].(string), "sha256:")) } + +// TestSharedUploaderUploadsOncePerAPI: helpers sharing the process-wide uploader do not +// upload what another one (or the reconciler) already uploaded to the same API (R62). +func TestSharedUploaderUploadsOncePerAPI(t *testing.T) { + bundle := writeBundle(t, "a") + api := &fakeAPI{} + server := httptest.NewServer(api) + t.Cleanup(server.Close) + client := sdk.NewClient(server.Client(), &sdk.Config{BaseURL: server.URL}) + shared := NewArtifactUploader() + + send := func(apiURL, title string) { + helper := NewApiHelper(hclog.NewNullLogger(), client, nil, "p", WithPolicyPaths([]string{bundle}), WithArtifactUploader(shared, apiURL)) + require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ + evidenceFor(title, &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), + })) + } + send(server.URL, "one") + send(server.URL, "two") + assert.Len(t, api.uploads, 2, "bundle and input once for both helpers") + + // The reconciler's endpoint for the same API sees them too. + tarball, err := policytree.TarDirectory(bundle) + require.NoError(t, err) + digest, err := shared.Endpoint(server.URL, client.Artifact).Upload(context.Background(), sdk.ArtifactMediaTypePolicyBundle, tarball) + require.NoError(t, err) + assert.NotEmpty(t, digest) + assert.Len(t, api.uploads, 2) + + // Another API does not have them. + send("http://other.example", "three") + assert.Len(t, api.uploads, 4) +} + +// TestPolicyPathIsResolvedWhenTheHelperIsCreated: the artifact of an inline bundle's stable +// path is the tree it pointed to when the run started, even if the agent swaps it later. +func TestPolicyPathIsResolvedWhenTheHelperIsCreated(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks need privileges on Windows") + } + base := t.TempDir() + first, second := filepath.Join(base, "v1"), filepath.Join(base, "v2") + for dir, title := range map[string]string{first: "one", second: "two"} { + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "p.rego"), []byte("package compliance_framework.p\n\ntitle := \""+title+"\"\n"), 0o644)) + } + require.NoError(t, os.Symlink("v1", filepath.Join(base, "current"))) + stable := filepath.Join(base, "current", ".") + + api := &fakeAPI{} + helper := newTestHelper(t, api, stable) + + // The agent swaps the link after the run started. + require.NoError(t, os.Symlink("v2", filepath.Join(base, "next"))) + require.NoError(t, os.Rename(filepath.Join(base, "next"), filepath.Join(base, "current"))) + + require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ + evidenceFor("one", &proto.PolicyEvaluation{PolicyPath: stable, Input: []byte(`{}`)}), + })) + tarball, err := policytree.TarDirectory(first) + require.NoError(t, err) + sum := sha256.Sum256(tarball) + refs := artifactsOf(api)["one"].(map[string]any) + assert.Equal(t, "sha256:"+hex.EncodeToString(sum[:]), refs["bundle-digest"], "the bundle must be the tree the run started with") +} diff --git a/runner/result.go b/runner/result.go index 615cd84..b206a53 100644 --- a/runner/result.go +++ b/runner/result.go @@ -16,19 +16,43 @@ type apiHelper struct { client *sdk.Client agentLabels map[string]string pluginName string - artifacts *artifactUploader + artifacts *ArtifactEndpoint + // policyPaths maps each policy path the plugin was given (cleaned) to the directory it + // resolved to when the helper was created. + policyPaths map[string]string // evidenceProps are appended to every evidence the plugin creates. evidenceProps []types.Property + + uploader *ArtifactUploader + apiURL string } type ApiHelperOption func(*apiHelper) // WithPolicyPaths sets the policy bundle paths the plugin was given. The agent uploads only -// these bundles as artifacts, so a plugin cannot make the agent read anything else. +// these bundles as artifacts, so a plugin cannot make the agent read anything else. Each +// path is resolved now: an inline bundle's stable path is a symlink the agent swaps between +// configuration runs, and the artifact must be the tree this run evaluated. func WithPolicyPaths(paths []string) ApiHelperOption { return func(h *apiHelper) { for _, path := range paths { - h.artifacts.policyPaths[filepath.Clean(path)] = struct{}{} + clean := filepath.Clean(path) + dir := clean + if resolved, err := filepath.EvalSymlinks(clean); err == nil { + dir = resolved + } + h.policyPaths[clean] = dir + } + } +} + +// WithArtifactUploader shares the process-wide uploader (R62), so what the reconciler or +// another plugin run already uploaded to the API at apiURL is not uploaded again. Without it +// the helper uses an uploader of its own. +func WithArtifactUploader(u *ArtifactUploader, apiURL string) ApiHelperOption { + return func(h *apiHelper) { + if u != nil { + h.uploader, h.apiURL = u, apiURL } } } @@ -49,11 +73,19 @@ func NewApiHelper(logger hclog.Logger, client *sdk.Client, agentLabels map[strin client: client, agentLabels: agentLabels, pluginName: pluginName, - artifacts: newArtifactUploader(client), + policyPaths: map[string]string{}, } for _, opt := range opts { opt(h) } + if h.uploader == nil { + h.uploader = NewArtifactUploader() + } + var artifacts ArtifactClient + if client != nil { + artifacts = client.Artifact + } + h.artifacts = h.uploader.Endpoint(h.apiURL, artifacts) return h } @@ -115,9 +147,9 @@ func (s *apiEvidenceSender) outcome(evaluation *proto.PolicyEvaluation) evaluati s.h.logger.Warn("Sending evidence without policy artifacts; it cannot be played back", "error", unknownEvaluation(evaluation.GetId())) } else { - refs, err := s.h.artifacts.storeEvaluation(s.ctx, evaluation) + refs, err := s.h.storeEvaluation(s.ctx, evaluation) switch { - case errors.Is(err, errArtifactsUnsupported): + case errors.Is(err, ErrArtifactsUnsupported): if !s.unsupported { s.unsupported = true s.h.logger.Warn("The API does not support policy artifacts; evidence is sent without them and cannot be played back. Upgrade the API.") From ebb5084a0368bcbe4ab966f9bd1e3a6ba624e47d Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:55:50 -0300 Subject: [PATCH 14/47] feat(inlinepolicy): policy contract checks and override hints (R63, R65) The e2e round showed revisions that compile but would record no evidence (an override without a title), and a compile error in a vendor test that did not say the override caused it. R63, after the compile and the tests pass, on the materialized tree: - static: policyeval.CheckContract on the vendor-only packages only (warnings; regocheck already checked the authored modules), and a warning for an authored package that more than one non-test module defines (each records evidence for the whole package); - dynamic: a dry run on {} plus policy_data through policyeval.Execute and policy-manager's GetRiskTemplates, the calls plugins make, under SandboxCapabilities with denied builtins rewritten to erroring stubs. Decode errors and Result.Issues become located PolicyErrors with the contract codes: errors in packages that contain an authored module, warnings in vendor-only ones. Conflicts on {} and a missing title whose title rule depends on the input are warnings. A package that fails to evaluate is left out of the next attempt so it does not hide others. R65: a compile error in a vendor file whose package an authored module also defines carries a hint (keep the rule or delete the test); it stays an error. Includes the exact e2e repro. policy-manager gains NewWithEvaluator and a RiskTemplateError that names the package and file. Test fixtures gain titles: authored packages without one are now rejected. Co-Authored-By: Claude Opus 5.5 --- cmd/inline_test.go | 6 +- internal/inlinepolicy/check.go | 47 ++- internal/inlinepolicy/contract.go | 341 +++++++++++++++++++++ internal/inlinepolicy/contract_test.go | 215 +++++++++++++ internal/inlinepolicy/inlinepolicy_test.go | 14 +- policy-manager/policy-manager.go | 26 +- 6 files changed, 633 insertions(+), 16 deletions(-) create mode 100644 internal/inlinepolicy/contract.go create mode 100644 internal/inlinepolicy/contract_test.go diff --git a/cmd/inline_test.go b/cmd/inline_test.go index 7814261..a594b2d 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -36,6 +36,8 @@ policy_bundles: import rego.v1 + title := "extra" + violation contains {"remarks": "x"} if input.max > data.max ` @@ -100,7 +102,7 @@ func TestInline_TransitiveDeniedBuiltinRejected(t *testing.T) { if err := os.WriteFile(filepath.Join(vendor, "lib.rego"), []byte("package ccf_libs.net\n\nimport rego.v1\n\nfetch(u) := http.send({\"method\": \"GET\", \"url\": u})\n"), 0o644); err != nil { t.Fatal(err) } - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\nimport data.ccf_libs.net\n\nviolation contains {\"remarks\": r} if r := net.fetch(\"http://x\")\n"}}}}`) + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\nimport data.ccf_libs.net\n\ntitle := \"extra\"\n\nviolation contains {\"remarks\": r} if r := net.fetch(\"http://x\")\n"}}}}`) mustStartup(t, h.rc) r := h.remote.lastReport(t) if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors || !strings.Contains(*r.Error, "http.send") { @@ -131,7 +133,7 @@ func TestInline_GCAfterSwap(t *testing.T) { h.remote.publish(0, `{}`) mustStartup(t, h.rc) for rev := int64(1); rev <= 10; rev++ { - overlay := fmt.Sprintf(`{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\n# rev %d\nviolation contains {\"remarks\": \"x\"} if input.max > data.max\n"}}}}`, rev) + overlay := fmt.Sprintf(`{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\n# rev %d\ntitle := \"extra\"\n\nviolation contains {\"remarks\": \"x\"} if input.max > data.max\n"}}}}`, rev) h.remote.publish(rev, overlay) if got := h.poll(t); got.overlay == nil || got.overlay.Revision != rev { r := h.remote.lastReport(t) diff --git a/internal/inlinepolicy/check.go b/internal/inlinepolicy/check.go index 3253cfb..06a85c7 100644 --- a/internal/inlinepolicy/check.go +++ b/internal/inlinepolicy/check.go @@ -39,9 +39,15 @@ type CheckInput struct { // rule through the compiled rule graph — including `with f as http.send` — is an error; // 3. tests: a failing authored _test.rego test is an error, a failing vendor test a warning. // Tests never run when step 2 found a denied builtin, and they run sandboxed: a denied -// builtin can never execute on the agent host (D17, HLD §8). +// builtin can never execute on the agent host (D17, HLD §8); +// 4. the policy contract (R63, contract.go): statically on vendor-only packages, then, when +// nothing so far is an error, a sandboxed dry run on an empty input. // -// The parse-level checks (regocheck) run once per bundle before materialization. +// A compile error in a vendor file whose package an authored module also defines carries a +// hint about the override that most likely caused it (R65). +// +// The parse-level checks (regocheck, including the static contract check of the authored +// modules) run once per bundle before materialization. func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { var out []agentconfig.PolicyError add := func(severity string, loc *ast.Location, format string, args ...any) { @@ -60,8 +66,9 @@ func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { _, err := policyeval.NewFromBundlePath(in.PolicyDir, in.PolicyData, policyeval.Options{}). PrepareForEval(ctx, rego.Query("data.compliance_framework"), rego.Package("compliance_framework")) if err != nil { + hints := compileHints(in) addCompileErrors(err, func(loc *ast.Location, msg string) { - add(agentconfig.SeverityError, loc, "%s", strings.ReplaceAll(msg, in.PolicyDir+string(filepath.Separator), "")) + add(agentconfig.SeverityError, loc, "%s", hints(loc, strings.ReplaceAll(msg, in.PolicyDir+string(filepath.Separator), ""))) }) agentconfig.SortPolicyErrors(out) return out @@ -78,10 +85,13 @@ func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { } compiler := ast.NewCompiler() if compiler.Compile(modules); compiler.Failed() { - addCompileErrors(compiler.Errors, func(loc *ast.Location, msg string) { add(agentconfig.SeverityError, loc, "%s", msg) }) + hints := compileHints(in) + addCompileErrors(compiler.Errors, func(loc *ast.Location, msg string) { add(agentconfig.SeverityError, loc, "%s", hints(loc, msg)) }) agentconfig.SortPolicyErrors(out) return out } + pkgs := packagesOf(modules, in.PolicyDir) + authoredPkgs := pkgs.authoredPackages(in.Authored) // 2. Transitive denied builtins. The revision is rejected, so the tests (which would // execute the denied builtin) never run. @@ -96,10 +106,39 @@ func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { // 3. Tests. out = append(out, runTests(ctx, in, b.Data, modules)...) + + // 4. Policy contract. + static, vendorSeen := staticContract(in, modules, pkgs, authoredPkgs) + out = append(out, static...) + if !agentconfig.HasPolicyErrors(out) { + out = append(out, dryRun(ctx, in, b, pkgs, authoredPkgs, vendorSeen)...) + } agentconfig.SortPolicyErrors(out) return out } +// compileHints returns a function that appends the R65 override hint to a compile error +// located in a vendor file. +func compileHints(in CheckInput) func(loc *ast.Location, msg string) string { + pkgs := treePackages{} + if files, _, err := readTree(in.PolicyDir); err == nil { + for _, f := range inventory(files) { + if f.Package != "" { + pkgs[f.Path] = f.Package + } + } + } + return func(loc *ast.Location, msg string) string { + if loc == nil { + return msg + } + if hint := overrideHint(relPath(in.PolicyDir, loc.File), pkgs, in.Authored); hint != "" { + return msg + " (hint: " + hint + ")" + } + return msg + } +} + func addCompileErrors(err error, add func(loc *ast.Location, msg string)) { var astErrs ast.Errors switch e := err.(type) { diff --git a/internal/inlinepolicy/contract.go b/internal/inlinepolicy/contract.go new file mode 100644 index 0000000..7d50337 --- /dev/null +++ b/internal/inlinepolicy/contract.go @@ -0,0 +1,341 @@ +package inlinepolicy + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "regexp" + "slices" + "strings" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/pkg/policyeval" + "github.com/hashicorp/go-hclog" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/open-policy-agent/opa/v1/bundle" + "github.com/open-policy-agent/opa/v1/rego" + "github.com/open-policy-agent/opa/v1/topdown" +) + +// The policy contract checks (R63). The API's regocheck runs the static contract check on +// the authored modules of every bundle before materialization; here the agent adds what only +// it can see, on the materialized tree: +// +// - static: policyeval.CheckContract on the packages no authored module touches (vendor +// debt: warnings only, R21/R34), and duplicate non-test modules in authored packages; +// - dynamic: a sandboxed dry run on an empty input through policyeval's Execute and +// policy-manager's GetRiskTemplates, exactly the calls plugins make. Problems in a +// package that contains an authored module are errors (as the contract rates them); +// in vendor-only packages they are warnings. Evaluation conflicts on {} are warnings: +// the real input may never trigger them. + +// treePackages maps every .rego file of the tree (relative path) to its package, without +// "data.". +type treePackages map[string]string + +func packagesOf(modules map[string]*ast.Module, root string) treePackages { + out := treePackages{} + for path, mod := range modules { + if mod != nil && mod.Package != nil { + out[relPath(root, path)] = strings.TrimPrefix(mod.Package.Path.String(), "data.") + } + } + return out +} + +// authoredPackages returns the packages that contain an authored module (tests included: +// a test module adds its rules to the package plugins evaluate). +func (p treePackages) authoredPackages(authored map[string]bool) map[string]bool { + out := map[string]bool{} + for file, pkg := range p { + if authored[file] { + out[pkg] = true + } + } + return out +} + +// filesOf returns the sorted files defining pkg, optionally only authored or only non-test ones. +func (p treePackages) filesOf(pkg string, keep func(file string) bool) []string { + var out []string + for file, fp := range p { + if fp == pkg && (keep == nil || keep(file)) { + out = append(out, file) + } + } + slices.Sort(out) + return out +} + +// overrideHint explains a compile error in a vendor file whose package an authored module +// also defines (R65): usually an override removed a rule the vendor's test still uses. +func overrideHint(file string, pkgs treePackages, authored map[string]bool) string { + pkg, ok := pkgs[file] + if !ok || file == "" || authored[file] { + return "" + } + overrides := pkgs.filesOf(pkg, func(f string) bool { return authored[f] }) + if len(overrides) == 0 { + return "" + } + kind := "vendor module" + if policyeval.IsTestFile(file) { + kind = "vendor test" + } + quoted := make([]string, len(overrides)) + for i, f := range overrides { + quoted[i] = "`" + f + "`" + } + return fmt.Sprintf("%s references rules removed by the override of %s; keep the rule or add `delete: [%s]`", kind, strings.Join(quoted, ", "), file) +} + +// staticContract runs the static contract check on the vendor-only packages of the tree +// (warnings), and flags authored packages that more than one non-test module defines, which +// regocheck cannot see because it only has the authored modules. It returns the issues and +// the (package, code) pairs it reported for vendor packages, so the dry run does not repeat +// them. +func staticContract(in CheckInput, modules map[string]*ast.Module, pkgs treePackages, authoredPkgs map[string]bool) ([]agentconfig.PolicyError, map[[2]string]bool) { + vendor := map[string]*ast.Module{} + for path, mod := range modules { + if pkg, ok := pkgs[relPath(in.PolicyDir, path)]; ok && !authoredPkgs[pkg] { + vendor[path] = mod + } + } + var out []agentconfig.PolicyError + seen := map[[2]string]bool{} + for _, issue := range policyeval.CheckContract(vendor) { + seen[[2]string{issue.Package, issue.Code}] = true + out = append(out, agentconfig.PolicyError{ + Bundle: in.Bundle, + Path: relPath(in.PolicyDir, issue.File), + Row: issue.Row, + Col: issue.Col, + Message: issue.Message + " (vendor package: a warning only)", + Severity: agentconfig.SeverityWarning, + Code: issue.Code, + }) + } + + for _, pkg := range sortedKeys(authoredPkgs) { + if !policyeval.IsPolicyPackage(pkg) { + continue + } + files := pkgs.filesOf(pkg, func(f string) bool { return !policyeval.IsTestFile(f) }) + if len(files) < 2 { + continue + } + out = append(out, agentconfig.PolicyError{ + Bundle: in.Bundle, + Path: files[1], + Message: fmt.Sprintf("package %s is defined by %d non-test modules (%s); plugins record evidence for the whole package once per module", pkg, len(files), strings.Join(files, ", ")), + Severity: agentconfig.SeverityWarning, + Code: policyeval.IssueDuplicatePackageModule, + }) + } + return out, seen +} + +// Codes of the dry-run problems that are not policyeval contract issues. +const ( + codeEvalError = "eval-error" + codeEvalConflict = "eval-conflict" + codeDryRunTimeout = "dry-run-timeout" +) + +// evalLocation matches the package and file policyeval.Execute names in its decode errors. +var evalLocation = regexp.MustCompile(` ?\(policy package "([^"]+)", file "([^"]*)"\)`) + +// dryRun evaluates the tree on an empty input the way a plugin does, sandboxed: denied +// builtins are rewritten to erroring stubs and the evaluator only has +// policyeval.SandboxCapabilities, so nothing reaches the network or the host. A package +// whose evaluation fails is reported and left out of the next attempt, so one broken +// package does not hide the others. +func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePackages, authoredPkgs map[string]bool, vendorSeen map[[2]string]bool) []agentconfig.PolicyError { + ctx, cancel := context.WithTimeout(ctx, TestTimeout) + defer cancel() + + parsed := make(map[string]*ast.Module, len(b.Modules)) + for _, mf := range b.Modules { + parsed[mf.Path] = mf.Parsed + } + sandboxed, stubs, caps := sandboxTestModules(parsed) + + var out []agentconfig.PolicyError + seen := map[string]bool{} // package + code + message + reportedCodes := map[[2]string]bool{} + add := func(pkg, file, code, severity, msg string) { + key := pkg + "\x00" + code + "\x00" + msg + if seen[key] { + return + } + seen[key] = true + reportedCodes[[2]string{pkg, code}] = true + out = append(out, agentconfig.PolicyError{ + Bundle: in.Bundle, + Path: file, + Message: prefixPlugin(in.Plugin, "on an empty input: "+msg), + Severity: severity, + Code: code, + }) + } + severityFor := func(pkg string) string { + if authoredPkgs[pkg] { + return agentconfig.SeverityError + } + return agentconfig.SeverityWarning + } + + excluded := map[string]bool{} + evaluator := func() (*policyeval.Evaluator, bool) { + dry := &bundle.Bundle{Data: b.Data, Manifest: b.Manifest.Copy()} + dry.Manifest.Init() + policies := false + for _, mf := range b.Modules { + pkg := pkgs[relPath(in.PolicyDir, mf.Path)] + if excluded[pkg] { + continue + } + mf.Parsed = sandboxed[mf.Path] + dry.Modules = append(dry.Modules, mf) + if policyeval.IsPolicyPackage(pkg) && !policyeval.IsTestFile(mf.Path) { + policies = true + } + } + loaders := []func(*rego.Rego){rego.ParsedBundle("inline", dry)} + for _, s := range stubs { + loaders = append(loaders, s.Func) + } + return policyeval.NewWithLoaders(loaders, in.PolicyData, policyeval.Options{Capabilities: caps}), policies + } + + // failure reports err and returns the package to leave out, or "" to stop. + failure := func(err error, code string) string { + if ctx.Err() != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + add("", "", codeDryRunTimeout, agentconfig.SeverityError, fmt.Sprintf("the dry run timed out after %s", TestTimeout)) + } + return "" + } + pkg, file := locateEvalError(err, in.PolicyDir, pkgs) + severity := severityFor(pkg) + var te *topdown.Error + switch msg := err.Error(); { + case errors.As(err, &te) && te.Code == topdown.ConflictErr: + severity = agentconfig.SeverityWarning + code = codeEvalConflict + case strings.Contains(msg, "decode violation entry") || strings.Contains(msg, "unexpected violations type"): + code = policyeval.IssueInvalidViolation + case strings.Contains(msg, "decode policy outputs") || strings.Contains(msg, "expected module outputs"): + code = policyeval.IssueInvalidType + } + if pkg == "" { + // Not attributable to a package: never reject on it. + add("", file, code, agentconfig.SeverityWarning, "could not evaluate the bundle: "+err.Error()) + return "" + } + if severity == agentconfig.SeverityWarning && vendorSeen[[2]string{pkg, code}] { + return pkg + } + msg := evalLocation.ReplaceAllString(err.Error(), "") + add(pkg, file, code, severity, fmt.Sprintf("package %s: %s", pkg, strings.ReplaceAll(msg, in.PolicyDir+string(filepath.Separator), ""))) + return pkg + } + + var ev *policyeval.Evaluator + for range len(pkgs) + 1 { + var policies bool + if ev, policies = evaluator(); !policies { + return out + } + results, err := ev.Execute(ctx, map[string]any{}) + if err != nil { + pkg := failure(err, codeEvalError) + if pkg == "" || excluded[pkg] { + return out + } + excluded[pkg] = true + continue + } + for _, r := range results { + pkg := r.Policy.Package.PurePackage() + file := relPath(in.PolicyDir, r.Policy.File) + for _, issue := range r.Issues { + severity := issue.Severity + msg := issue.Message + if !authoredPkgs[pkg] { + if vendorSeen[[2]string{pkg, issue.Code}] { + continue + } + severity = agentconfig.SeverityWarning + } + if issue.Code == policyeval.IssueMissingTitle && hasRule(parsed, pkg, "title") { + // The title depends on the input; the static check rates that a warning. + severity = agentconfig.SeverityWarning + } + add(pkg, file, issue.Code, severity, msg) + } + } + break + } + + // Risk templates, through the same call plugins make at Init. + for range len(pkgs) + 1 { + if ev == nil { + break + } + _, err := policyManager.NewWithEvaluator(hclog.NewNullLogger(), ev).GetRiskTemplates(ctx) + if err == nil { + break + } + var rte *policyManager.RiskTemplateError + if errors.As(err, &rte) && (reportedCodes[[2]string{rte.Package, policyeval.IssueInvalidRiskTemplate}] || reportedCodes[[2]string{rte.Package, policyeval.IssueInvalidType}]) { + // ValidateResult already reported this package's risk templates. + excluded[rte.Package] = true + } else { + pkg := failure(err, policyeval.IssueInvalidRiskTemplate) + if pkg == "" || excluded[pkg] { + break + } + excluded[pkg] = true + } + var policies bool + if ev, policies = evaluator(); !policies { + break + } + } + return out +} + +// locateEvalError finds the package (without "data.") and file an evaluation error is about. +func locateEvalError(err error, root string, pkgs treePackages) (pkg, file string) { + var rte *policyManager.RiskTemplateError + if errors.As(err, &rte) { + return rte.Package, relPath(root, rte.File) + } + var te *topdown.Error + if errors.As(err, &te) && te.Location != nil && te.Location.File != "" { + file = relPath(root, te.Location.File) + return pkgs[file], file + } + if m := evalLocation.FindStringSubmatch(err.Error()); m != nil { + return strings.TrimPrefix(m[1], "data."), relPath(root, m[2]) + } + return "", "" +} + +// hasRule reports whether any module of pkg defines a rule named name. +func hasRule(modules map[string]*ast.Module, pkg, name string) bool { + for _, mod := range modules { + if mod == nil || mod.Package == nil || strings.TrimPrefix(mod.Package.Path.String(), "data.") != pkg { + continue + } + for _, rule := range mod.Rules { + if ref := rule.Head.Ref(); len(ref) > 0 && ref[0].Value.Compare(ast.Var(name)) == 0 { + return true + } + } + } + return false +} diff --git a/internal/inlinepolicy/contract_test.go b/internal/inlinepolicy/contract_test.go new file mode 100644 index 0000000..2b25e7f --- /dev/null +++ b/internal/inlinepolicy/contract_test.go @@ -0,0 +1,215 @@ +package inlinepolicy + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/pkg/policyeval" +) + +// The e2e repro of §13.1: the UI's Override replaced the vendor module with a skeleton, so the +// vendor test in the same package no longer compiles. +const ( + sshVendorModule = `package compliance_framework.ssh_deny_password_auth + +import rego.v1 + +title := "SSH password authentication is disabled" + +violation contains {"id": "password-auth", "title": "Password authentication is enabled"} if { + input.passwordauthentication == "yes" +} +` + sshVendorTest = `package compliance_framework.ssh_deny_password_auth + +import rego.v1 + +test_password_auth_denied if { + count(violation) == 1 with input as {"passwordauthentication": "yes"} +} +` + sshOverrideSkeleton = "package compliance_framework.ssh_deny_password_auth\n\nimport rego.v1\n" +) + +func TestCheck_OverrideBreaksVendorTest_R65(t *testing.T) { + vendor := map[string]string{ + "ssh/ssh_deny_password_auth.rego": sshVendorModule, + "ssh/ssh_deny_password_auth_test.rego": sshVendorTest, + } + + m := materialize(t, vendor, &agentconfig.PolicyBundle{ + Extends: strptr("ghcr.io/vendor/policies:v1"), + Modules: map[string]string{"ssh/ssh_deny_password_auth.rego": sshOverrideSkeleton}, + }) + errs := errorsOf(check(m, nil), agentconfig.SeverityError) + if len(errs) == 0 { + t.Fatal("a vendor test that no longer compiles must reject the revision") + } + var found bool + for _, e := range errs { + if e.Path == "ssh/ssh_deny_password_auth_test.rego" && strings.Contains(e.Message, "violation") && + strings.Contains(e.Message, "vendor test references rules removed by the override of `ssh/ssh_deny_password_auth.rego`; keep the rule or add `delete: [ssh/ssh_deny_password_auth_test.rego]`") { + found = true + } + } + if !found { + t.Fatalf("expected the compile error in the vendor test with the override hint, got %+v", errs) + } + + // Deleting the vendor test makes the bundle compile; the skeleton still has no title, so + // its package would record no evidence: that is an error for an authored package. + m = materialize(t, vendor, &agentconfig.PolicyBundle{ + Extends: strptr("ghcr.io/vendor/policies:v1"), + Delete: []string{"ssh/ssh_deny_password_auth_test.rego"}, + Modules: map[string]string{"ssh/ssh_deny_password_auth.rego": sshOverrideSkeleton}, + }) + errs = errorsOf(check(m, nil), agentconfig.SeverityError) + if len(errs) != 1 || errs[0].Code != policyeval.IssueMissingTitle || errs[0].Path != "ssh/ssh_deny_password_auth.rego" { + t.Fatalf("expected one missing-title error on the override, got %+v", errs) + } + + // No hint on a compile error in an authored file. + m = materialize(t, vendor, &agentconfig.PolicyBundle{ + Extends: strptr("ghcr.io/vendor/policies:v1"), + Modules: map[string]string{"ssh/ssh_deny_password_auth.rego": sshVendorModule + "\nbroken if { undefined_var }\n"}, + }) + for _, e := range errorsOf(check(m, nil), agentconfig.SeverityError) { + if strings.Contains(e.Message, "hint:") { + t.Fatalf("an authored compile error must not carry the vendor hint: %+v", e) + } + } +} + +func codes(errs []agentconfig.PolicyError, path string) map[string]string { + out := map[string]string{} + for _, e := range errs { + if e.Path == path { + out[e.Code] = e.Severity + } + } + return out +} + +func TestCheck_Contract_R63(t *testing.T) { + vendor := map[string]string{ + "banner.rego": vendorBanner, + "untitled.rego": "package compliance_framework.untitled\n\nviolation contains {\"id\": \"u\"} if input.x\n", + "badvendor.rego": "package compliance_framework.badvendor\n\ntitle := \"bad vendor\"\n\nviolation contains v if { v := \"not-an-object\" }\n", + } + ext := strptr("ghcr.io/vendor/policies:v1") + + t.Run("authored decode error is an error, vendor one a warning, both reported", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nviolation contains v if { v := 42 }\n", + }}) + res := check(m, nil) + if got := codes(res, "x.rego")[policyeval.IssueInvalidViolation]; got != agentconfig.SeverityError { + t.Fatalf("expected an eval error on the authored package, got %+v", res) + } + if got := codes(res, "badvendor.rego")[policyeval.IssueInvalidViolation]; got != agentconfig.SeverityWarning { + t.Fatalf("expected a warning on the vendor package, got %+v", res) + } + n := 0 + for _, e := range res { + if e.Path == "badvendor.rego" { + n++ + } + } + if n != 1 { + t.Fatalf("the static and dynamic checks must not both report the vendor package, got %+v", res) + } + }) + + t.Run("vendor-only problems warn once", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n", + }}) + res := check(m, nil) + if errs := errorsOf(res, agentconfig.SeverityError); len(errs) != 0 { + t.Fatalf("vendor debt must not reject, got %+v", errs) + } + n := 0 + for _, e := range res { + if e.Path == "untitled.rego" && e.Code == policyeval.IssueMissingTitle { + n++ + } + } + if n != 1 { + t.Fatalf("expected exactly one missing-title warning for the vendor package, got %d in %+v", n, res) + } + }) + + t.Run("authored package without a title is rejected", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\nviolation contains {\"id\": \"x\"} if input.x\n", + }}) + if got := codes(check(m, nil), "x.rego")[policyeval.IssueMissingTitle]; got != agentconfig.SeverityError { + t.Fatalf("expected a missing-title error, got %q", got) + } + }) + + t.Run("a title that depends on the input only warns", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"x\" if input.enabled\n", + }}) + if got := codes(check(m, nil), "x.rego")[policyeval.IssueMissingTitle]; got != agentconfig.SeverityWarning { + t.Fatalf("expected a missing-title warning, got %q", got) + } + }) + + t.Run("an evaluation conflict on an empty input only warns", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nv := 1\n\nv := 2 if not input.y\n", + }}) + if got := codes(check(m, nil), "x.rego")[codeEvalConflict]; got != agentconfig.SeverityWarning { + t.Fatalf("expected an eval-conflict warning, got %q", got) + } + }) + + t.Run("invalid risk templates of an authored package are an error", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nrisk_templates := [{\"name\": \"r\"}] if true\n", + }}) + got := codes(check(m, nil), "x.rego") + if got[policyeval.IssueInvalidRiskTemplate] != agentconfig.SeverityError { + t.Fatalf("expected an invalid-risk-template error, got %v", got) + } + }) + + t.Run("an authored module joining a vendor package duplicates its evidence", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "banner_extra.rego": "package compliance_framework.banner\n\nremarks := \"more\"\n", + }}) + res := check(m, nil) + if got := codes(res, "banner_extra.rego")[policyeval.IssueDuplicatePackageModule]; got != agentconfig.SeverityWarning { + t.Fatalf("expected a duplicate-package-module warning, got %+v", res) + } + }) +} + +// TestCheck_DryRunIsSandboxed: the dry run evaluates vendor packages too, and a denied +// builtin they call must never execute. +func TestCheck_DryRunIsSandboxed(t *testing.T) { + var hits atomic.Int32 + probe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer probe.Close() + + m := materialize(t, map[string]string{ + "leak.rego": "package compliance_framework.leak\n\ntitle := \"leak\"\n\nr := http.send({\"method\": \"GET\", \"url\": \"" + probe.URL + "\"})\n", + }, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n", + }}) + if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { + t.Fatalf("unexpected errors %+v", errs) + } + if n := hits.Load(); n != 0 { + t.Fatalf("the probe server received %d request(s) during the dry run", n) + } +} diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go index 928077b..b4c4e54 100644 --- a/internal/inlinepolicy/inlinepolicy_test.go +++ b/internal/inlinepolicy/inlinepolicy_test.go @@ -52,7 +52,7 @@ func readFile(t *testing.T, dir, p string) string { return string(raw) } -const vendorBanner = "package compliance_framework.banner\n\nviolation contains {\"remarks\": \"no banner\"} if not input.banner\n" +const vendorBanner = "package compliance_framework.banner\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"no-banner\", \"remarks\": \"no banner\"} if not input.banner\n" const vendorMaxAuth = "package compliance_framework.max_auth\n\nviolation contains {\"remarks\": \"too many\"} if input.max_auth > 3\n" func TestMaterialize_R17Order(t *testing.T) { @@ -300,7 +300,7 @@ func TestCheck_CompileAndBuiltins(t *testing.T) { t.Run("pure builtins are allowed", func(t *testing.T) { m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\na := net.cidr_contains(\"10.0.0.0/8\", \"10.1.2.3\")\n\nb := rego.parse_module(\"x.rego\", \"package x\")\n\nc if trace(\"hi\")\n", + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\na := net.cidr_contains(\"10.0.0.0/8\", \"10.1.2.3\")\n\nb := rego.parse_module(\"x.rego\", \"package x\")\n\nc if trace(\"hi\")\n", }}) if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { t.Fatalf("unexpected errors %v", errs) @@ -309,7 +309,7 @@ func TestCheck_CompileAndBuiltins(t *testing.T) { t.Run("vendor-only denied builtins are not attributed to authored rules", func(t *testing.T) { m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\nr := net.helper(1)\n", + "x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\ntitle := \"x\"\n\nr := net.helper(1)\n", }}) if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { t.Fatalf("unexpected errors %v", errs) @@ -321,7 +321,7 @@ func TestCheck_CompileAndBuiltins(t *testing.T) { ".manifest": `{"roots": ["compliance_framework/banner"]}`, "banner.rego": vendorBanner, }, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\nr := 1\n", + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n", }}) if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) == 0 { t.Fatal("a package outside the manifest roots must be rejected") @@ -363,7 +363,7 @@ func TestCheck_DeniedBuiltinNeverExecutes(t *testing.T) { withTest[k] = v } m := materialize(t, withTest, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\nr := 1\n", + "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n", }}) res := check(m, nil) if errs := errorsOf(res, agentconfig.SeverityError); len(errs) != 0 { @@ -385,7 +385,7 @@ func TestCheck_Tests(t *testing.T) { "banner_test.rego": "package compliance_framework.banner_test\n\nimport data.compliance_framework.banner\n\ntest_fails if { count(banner.violation) == 42 with input as {} }\n", } t.Run("failing vendor test warns", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{"x.rego": "package compliance_framework.x\n\nr := 1\n"}}) + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n"}}) res := check(m, nil) if len(errorsOf(res, agentconfig.SeverityError)) != 0 || len(errorsOf(res, agentconfig.SeverityWarning)) != 1 { t.Fatalf("expected exactly one warning, got %+v", res) @@ -426,7 +426,7 @@ func TestCheck_Tests(t *testing.T) { // TestMergePolicyDataParity checks that the test store sees the same data a plugin evaluates. func TestMergePolicyDataParity(t *testing.T) { m := materialize(t, map[string]string{}, &agentconfig.PolicyBundle{ - Modules: map[string]string{"x.rego": "package compliance_framework.x\n\nr := 1\n"}, + Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n"}, Data: map[string]any{"a": map[string]any{"x": 1, "y": 2}, "list": []any{1}}, }) policyData := map[string]any{"a": map[string]any{"y": 3, "z": map[string]any{"q": true}}, "list": []any{2}, "b": 5} diff --git a/policy-manager/policy-manager.go b/policy-manager/policy-manager.go index a583108..3507d50 100644 --- a/policy-manager/policy-manager.go +++ b/policy-manager/policy-manager.go @@ -34,6 +34,26 @@ func New(ctx context.Context, logger hclog.Logger, policyPath string, policyData } } +// NewWithEvaluator wraps an evaluator built by the caller, for example one restricted to +// policyeval.SandboxCapabilities. The agent uses it to dry-run inline bundles exactly the way +// plugins evaluate them. +func NewWithEvaluator(logger hclog.Logger, evaluator *policyeval.Evaluator) *PolicyManager { + return &PolicyManager{logger: logger, evaluator: evaluator} +} + +// RiskTemplateError is a failure to read the risk_templates of one policy package. +type RiskTemplateError struct { + Package string // without the leading "data." + File string + Err error +} + +func (e *RiskTemplateError) Error() string { + return fmt.Sprintf("risk_templates of package %s (%s): %v", e.Package, e.File, e.Err) +} + +func (e *RiskTemplateError) Unwrap() error { return e.Err } + func (pm *PolicyManager) prepareForEval(ctx context.Context, regoArgs ...func(r *rego.Rego)) (rego.PreparedEvalQuery, error) { return pm.evaluator.PrepareForEval(ctx, regoArgs...) } @@ -266,7 +286,7 @@ func (pm *PolicyManager) GetRiskTemplates(ctx context.Context) (map[string][]*pr riskTemplates, err := pm.evaluateRiskTemplates(ctx, policy) if err != nil { - return nil, err + return nil, &RiskTemplateError{Package: purePackage, File: policy.File, Err: err} } if _, exists := allTemplates[purePackage]; !exists { @@ -277,12 +297,12 @@ func (pm *PolicyManager) GetRiskTemplates(ctx context.Context) (map[string][]*pr for _, riskTemplate := range riskTemplates { temp := &RiskTemplate{} if err := mapstructure.Decode(riskTemplate, temp); err != nil { - return nil, err + return nil, &RiskTemplateError{Package: purePackage, File: policy.File, Err: err} } template, err := newProtoRiskTemplate(policy, temp) if err != nil { - return nil, err + return nil, &RiskTemplateError{Package: purePackage, File: policy.File, Err: err} } moduleTemplates = append(moduleTemplates, template) From 71c3da876bc3266f92a09b1f0330bc5d25e15b24 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:56:23 -0300 Subject: [PATCH 15/47] feat(inlinepolicy): stable per-bundle path for evidence identity (R67) policy-manager seeds evidence UUIDs with the policy file path, and inline bundles lived in tree-digest directories, so every edit of a bundle gave every package in it a new evidence identity and broke history. Materialized trees stay write-once and content-addressed, now at //bundle; Materialized.Path is /current/bundle, the same for every revision. Activate points /current at a tree with a temporary symlink renamed over it. The symlink is an intermediate path component on purpose: OPA's bundle loader does not descend into a symlinked root directory and would silently load nothing. The swap is atomic for path lookups on Linux, but neither a snapshot for a reader walking the tree nor atomic on macOS APFS, so callers swap only when no plugin of the previous configuration runs, and serialize Activate with GC. GC never removes the tree current points to, and cleans up stale temporary links. Without symlink support (Windows without the privilege) Path is the tree itself, as before. A tree directory in the pre-R67 layout is rebuilt rather than reused. readTree is policytree.ReadTree. Co-Authored-By: Claude Opus 5.5 --- internal/inlinepolicy/activate_test.go | 248 +++++++++++++++++++++ internal/inlinepolicy/inlinepolicy_test.go | 2 +- internal/inlinepolicy/materialize.go | 187 +++++++++++----- 3 files changed, 384 insertions(+), 53 deletions(-) create mode 100644 internal/inlinepolicy/activate_test.go diff --git a/internal/inlinepolicy/activate_test.go b/internal/inlinepolicy/activate_test.go new file mode 100644 index 0000000..e13f584 --- /dev/null +++ b/internal/inlinepolicy/activate_test.go @@ -0,0 +1,248 @@ +package inlinepolicy + +import ( + "context" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/hashicorp/go-hclog" +) + +func skipWithoutSymlinks(t *testing.T, root string) { + t.Helper() + if runtime.GOOS == "windows" || !symlinksSupported(root) { + t.Skip("symlinks are not available") + } +} + +const stablePkg = "package compliance_framework.stable\n\ntitle := \"stable\"\n\nviolation contains {\"id\": \"s\"} if input.bad\n" + +// evidenceOf evaluates the policy path the way a plugin does and returns, per package, the +// evidence UUID and the policy file policy-manager seeds it with. +func evidenceOf(t *testing.T, policyPath string) map[string][2]string { + t.Helper() + pm := policyManager.New(context.Background(), hclog.NewNullLogger(), policyPath, nil) + results, err := pm.Execute(context.Background(), map[string]any{"bad": true}) + if err != nil { + t.Fatal(err) + } + evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), map[string]string{"plugin": "ssh"}, nil, nil, nil, nil, nil, nil). + GenerateResults(context.Background(), policyPath, map[string]any{"bad": true}) + if err != nil { + t.Fatal(err) + } + if len(evidence) != len(results) { + t.Fatalf("expected one evidence per result: %d vs %d", len(evidence), len(results)) + } + out := map[string][2]string{} + for _, r := range results { + pkg := r.Policy.Package.PurePackage() + for _, e := range evidence { + if e.Labels["_policy"] == pkg { + out[pkg] = [2]string{e.UUID, r.Policy.File} + } + } + } + return out +} + +// TestActivate_EvidenceIdentityIsStable_R67: two inline revisions that only change another +// package give the unchanged package the same policy_file, so the same evidence UUID. +func TestActivate_EvidenceIdentityIsStable_R67(t *testing.T) { + root := t.TempDir() + skipWithoutSymlinks(t, root) + revision := func(other string) *Materialized { + m, err := Materialize(context.Background(), root, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ + "stable.rego": stablePkg, + "other.rego": "package compliance_framework.other\n\ntitle := \"" + other + "\"\n", + }}, nil) + if err != nil { + t.Fatal(err) + } + if err := Activate(root, "ssh", m.Dir); err != nil { + t.Fatal(err) + } + return m + } + + first := revision("v1") + before := evidenceOf(t, first.Path) + second := revision("v2") + after := evidenceOf(t, second.Path) + + if first.Dir == second.Dir || first.Path != second.Path { + t.Fatalf("the trees must differ and the stable path must not: %s %s / %s %s", first.Dir, second.Dir, first.Path, second.Path) + } + if want := filepath.Join(root, "ssh", "current", "bundle"); first.Path != want { + t.Fatalf("stable path = %s, want %s", first.Path, want) + } + const stable, other = "compliance_framework.stable", "compliance_framework.other" + if before[stable] != after[stable] || before[stable][0] == "" { + t.Fatalf("an unchanged package must keep its evidence UUID and policy_file: %v vs %v", before[stable], after[stable]) + } + if before[other][1] != after[other][1] || after[other][0] == "" { + t.Fatalf("policy_file must not depend on the revision: %v vs %v", before[other], after[other]) + } + + // The stable path resolves to the active tree, so an artifact upload of it is the tree. + resolved, err := filepath.EvalSymlinks(second.Path) + if err != nil { + t.Fatal(err) + } + wantDir, _ := filepath.EvalSymlinks(second.Dir) + if resolved != wantDir { + t.Fatalf("stable path resolves to %s, want %s", resolved, wantDir) + } +} + +func TestActivate_RejectsForeignDirs(t *testing.T) { + root := t.TempDir() + skipWithoutSymlinks(t, root) + for _, dir := range []string{t.TempDir(), filepath.Join(root, "other", "abc", "bundle"), filepath.Join(root, "ssh", "abc")} { + if err := Activate(root, "ssh", dir); err == nil { + t.Fatalf("activating %s must fail", dir) + } + } + if err := Activate(root, "ssh", filepath.Join(root, "ssh", "abc", "bundle")); err == nil { + t.Fatal("activating a missing tree must fail") + } +} + +// TestGC_NeverRemovesCurrent: the tree current points to survives GC even when nothing keeps +// it and it is the oldest. +func TestGC_NeverRemovesCurrent(t *testing.T) { + root := t.TempDir() + skipWithoutSymlinks(t, root) + var dirs []string + for _, title := range []string{"a", "b", "c"} { + m, err := Materialize(context.Background(), root, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"" + title + "\"\n", + }}, nil) + if err != nil { + t.Fatal(err) + } + dirs = append(dirs, m.Dir) + } + if err := Activate(root, "ssh", dirs[0]); err != nil { + t.Fatal(err) + } + // A crash between creating and renaming the temporary link leaves it behind. + if err := os.Symlink("x", filepath.Join(root, "ssh", currentTmpPrefix+"stale")); err != nil { + t.Fatal(err) + } + if err := GC(root, nil, 0); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dirs[0]); err != nil { + t.Fatalf("the active tree was removed: %v", err) + } + for _, d := range dirs[1:] { + if _, err := os.Stat(filepath.Dir(d)); !os.IsNotExist(err) { + t.Fatalf("%s should have been collected", d) + } + } + if _, err := os.Lstat(filepath.Join(root, "ssh", currentTmpPrefix+"stale")); !os.IsNotExist(err) { + t.Fatal("a stale temporary link must be removed") + } + if _, err := os.Stat(filepath.Join(root, "ssh", "current", "bundle", "x.rego")); err != nil { + t.Fatalf("the stable path must still resolve: %v", err) + } +} + +// TestActivate_SwapIsAtomic: on Linux, readers resolving the stable path while it is swapped +// always find one of the two trees, never a missing path. (A reader walking the tree across a +// swap can still mix files of both trees, and on macOS APFS a lookup racing the rename can +// fail with EINVAL; the agent therefore swaps only between configuration runs, never while a +// plugin of the previous configuration runs.) +func TestActivate_SwapIsAtomic(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("rename(2) over a symlink is atomic for concurrent lookups on Linux only") + } + root := t.TempDir() + skipWithoutSymlinks(t, root) + var trees [2]*Materialized + for i, title := range []string{"a", "b"} { + m, err := Materialize(context.Background(), root, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ + "x.rego": "package compliance_framework.x\n\ntitle := \"" + title + "\"\n", + }}, nil) + if err != nil { + t.Fatal(err) + } + trees[i] = m + } + if err := Activate(root, "ssh", trees[0].Dir); err != nil { + t.Fatal(err) + } + want := map[string]bool{} + for _, m := range trees { + resolved, err := filepath.EvalSymlinks(m.Dir) + if err != nil { + t.Fatal(err) + } + want[resolved] = true + } + + var stop atomic.Bool + var failures atomic.Int32 + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for !stop.Load() { + resolved, err := filepath.EvalSymlinks(trees[0].Path) + if err != nil || !want[resolved] { + failures.Add(1) + } + if _, err := os.ReadFile(filepath.Join(trees[0].Path, "x.rego")); err != nil { + failures.Add(1) + } + } + }() + } + for i := range 500 { + if err := Activate(root, "ssh", trees[(i+1)%2].Dir); err != nil { + t.Error(err) + break + } + } + stop.Store(true) + wg.Wait() + if n := failures.Load(); n != 0 { + t.Fatalf("%d reads did not find a complete tree during the swaps", n) + } +} + +// TestMaterialize_RebuildsOldLayout: a tree directory from the layout before R67 (files +// directly under ) is rebuilt instead of being reused without its bundle/ directory. +func TestMaterialize_RebuildsOldLayout(t *testing.T) { + root := t.TempDir() + b := &agentconfig.PolicyBundle{Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n"}} + m, err := Materialize(context.Background(), root, "ssh", b, nil) + if err != nil { + t.Fatal(err) + } + version := filepath.Dir(m.Dir) + if err := os.RemoveAll(version); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(version, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(version, "x.rego"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + again, err := Materialize(context.Background(), root, "ssh", b, nil) + if err != nil { + t.Fatal(err) + } + if got := readFile(t, again.Dir, "x.rego"); got != b.Modules["x.rego"] { + t.Fatalf("the old layout was not rebuilt: %q", got) + } +} diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go index b4c4e54..f511c96 100644 --- a/internal/inlinepolicy/inlinepolicy_test.go +++ b/internal/inlinepolicy/inlinepolicy_test.go @@ -119,7 +119,7 @@ func TestMaterialize_R17Order(t *testing.T) { if m.Extends == nil || m.Extends.Source != "ghcr.io/vendor/policies:v1" || len(m.Extends.Files) != 12 { t.Fatalf("extends report wrong: %+v", m.Extends) } - if !strings.HasPrefix(m.Digest, agentconfig.TreeDigestPrefix) || filepath.Base(m.Dir) != strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix) { + if !strings.HasPrefix(m.Digest, agentconfig.TreeDigestPrefix) || filepath.Base(filepath.Dir(m.Dir)) != strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix) { t.Fatalf("digest/dir mismatch: %s %s", m.Digest, m.Dir) } for _, f := range m.Files { diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 72c7d27..7812efe 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -2,8 +2,9 @@ // remote overlay) into write-once directories that plugins load like any other policy path, // and checks them the way plugins will evaluate them (HLD §3.5, R17–R21). // -// The package is a leaf: it imports api/pkg/agentconfig, api/pkg/agentconfig/regocheck, -// api/pkg/policyeval, OPA v1 and the standard library, never cmd. +// It imports api/pkg/agentconfig, api/pkg/policyeval, internal/policytree, policy-manager +// (to dry-run bundles through the exact calls plugins make), OPA v1 and the standard +// library, never cmd. package inlinepolicy import ( @@ -14,14 +15,15 @@ import ( "encoding/json" "errors" "fmt" - "io/fs" "os" "path" "path/filepath" "slices" "sort" "strings" + "sync" + "github.com/compliance-framework/agent/internal/policytree" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/open-policy-agent/opa/v1/ast" "sigs.k8s.io/yaml" @@ -51,13 +53,31 @@ func (p PolicyErrors) Error() string { } // Materialized is one bundle written to disk. +// +// Layout under root (R67): +// +// //bundle/... the tree, write-once and content-addressed (Dir) +// /current -> swapped atomically by Activate +// +// Plugins receive Path, /current/bundle: the same path for every revision of the +// bundle, so the policy_file of an unchanged package, and with it the evidence UUID that +// policy-manager seeds with it, survives edits of other files. The symlink is an +// intermediate path component on purpose: OPA's bundle loader does not descend into a +// symlinked root directory, but resolves a symlink earlier in the path like any other. type Materialized struct { - Name string - Dir string // // + Name string + // Dir is the tree itself (///bundle). The checks run on it and its + // contents never change. + Dir string + // Path is what plugins receive: //current/bundle, or Dir when the file + // system has no symlinks (then evidence identity changes with each revision, as before). + Path string Digest string // agentconfig.BundleTreeDigest(files) // Extends describes the vendor tree the bundle extends (nil when standalone). Extends *agentconfig.PolicyBundleExtendsReport - Files []agentconfig.PolicyFileReport + // ExtendsDir is the directory the extends tree was read from. + ExtendsDir string + Files []agentconfig.PolicyFileReport // Authored are the paths written from Modules (and data.json when Data is set). Authored map[string]bool AuthoredTests []string @@ -108,6 +128,7 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu Digest: agentconfig.BundleTreeDigest(baseFiles), Files: inventory(baseFiles), } + m.ExtendsDir = dir files = baseFiles } @@ -180,7 +201,11 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu if err := writeOnce(final, files); err != nil { return nil, err } - m.Dir = final + m.Dir = filepath.Join(final, treeDir) + m.Path = m.Dir + if symlinksSupported(root) { + m.Path = filepath.Join(root, name, currentLink, treeDir) + } // 7. Inventory. m.Files = inventory(files) @@ -238,40 +263,9 @@ func mergeRootData(files map[string][]byte, data map[string]any) error { return nil } -// readTree reads the regular files under dir (paths relative, slash-separated). dir itself -// may be a symlink (e.g. /etc/ccf/policies -> a versioned directory); symlinks inside the -// tree are skipped and returned. +// readTree reads a policy tree the way every consumer does (see policytree.ReadTree). func readTree(dir string) (map[string][]byte, []string, error) { - files := map[string][]byte{} - var skipped []string - dir, err := filepath.EvalSymlinks(dir) - if err != nil { - return nil, nil, err - } - err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(dir, p) - if err != nil { - return err - } - rel = filepath.ToSlash(rel) - if d.Type()&fs.ModeSymlink != 0 { - skipped = append(skipped, rel) - return nil - } - if d.IsDir() || !d.Type().IsRegular() { - return nil - } - raw, err := os.ReadFile(p) - if err != nil { - return err - } - files[rel] = raw - return nil - }) - return files, skipped, err + return policytree.ReadTree(dir) } // Inventory digests and lists a policy directory (OCI or local sources) for the report. @@ -298,25 +292,40 @@ func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { return out } -// writeOnce writes files into final unless it already exists: a temp dir (dirs 0755, files -// 0644) renamed into place. Losing a rename race reuses the winner's directory. +const ( + // treeDir is the directory holding the policy tree inside a materialized directory. + treeDir = "bundle" + // currentLink is the per-bundle symlink to the active materialized directory. + currentLink = "current" + // Name prefixes of transient entries in a bundle directory. + tmpPrefix = ".tmp-" + currentTmpPrefix = ".current-" + symlinkProbeEntry = ".symlink-probe-" +) + +// writeOnce writes files under final/bundle unless that already exists: a temp dir (dirs +// 0755, files 0644) renamed into place. Losing a rename race reuses the winner's directory. func writeOnce(final string, files map[string][]byte) error { - if info, err := os.Stat(final); err == nil && info.IsDir() { + if info, err := os.Stat(filepath.Join(final, treeDir)); err == nil && info.IsDir() { return nil } + if _, err := os.Lstat(final); err == nil { + // A directory in an older layout (the tree directly under final): rebuild it. + if err := os.RemoveAll(final); err != nil { + return err + } + } parent := filepath.Dir(final) if err := os.MkdirAll(parent, 0o755); err != nil { return err } - var rnd [6]byte - _, _ = rand.Read(rnd[:]) - tmp := filepath.Join(parent, ".tmp-"+hex.EncodeToString(rnd[:])) - if err := os.MkdirAll(tmp, 0o755); err != nil { + tmp := filepath.Join(parent, tmpPrefix+randomSuffix()) + if err := os.MkdirAll(filepath.Join(tmp, treeDir), 0o755); err != nil { return err } cleanup := func() { _ = os.RemoveAll(tmp) } for p, content := range files { - dst := filepath.Join(tmp, filepath.FromSlash(p)) + dst := filepath.Join(tmp, treeDir, filepath.FromSlash(p)) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { cleanup() return err @@ -328,7 +337,7 @@ func writeOnce(final string, files map[string][]byte) error { } if err := os.Rename(tmp, final); err != nil { cleanup() - if info, statErr := os.Stat(final); statErr == nil && info.IsDir() { + if info, statErr := os.Stat(filepath.Join(final, treeDir)); statErr == nil && info.IsDir() { return nil } return err @@ -336,8 +345,72 @@ func writeOnce(final string, files map[string][]byte) error { return nil } -// GC removes materialized directories under root except those in keep and the perBundle -// newest per bundle, plus abandoned temp directories. +func randomSuffix() string { + var rnd [6]byte + _, _ = rand.Read(rnd[:]) + return hex.EncodeToString(rnd[:]) +} + +var symlinkSupport sync.Map // root -> bool + +// symlinksSupported reports, once per root, whether symlinks can be created under root +// (not on Windows without the privilege, nor on some network or FAT file systems). +func symlinksSupported(root string) bool { + if v, ok := symlinkSupport.Load(root); ok { + return v.(bool) + } + ok := func() bool { + if err := os.MkdirAll(root, 0o755); err != nil { + return false + } + probe := filepath.Join(root, symlinkProbeEntry+randomSuffix()) + defer func() { _ = os.Remove(probe) }() + return os.Symlink(".", probe) == nil + }() + symlinkSupport.Store(root, ok) + return ok +} + +// Activate points bundle name's stable path (Materialized.Path) at dir, a Materialized.Dir +// of that bundle under root. The swap is atomic: a temporary symlink renamed over +// /current, so on Linux a reader resolving the stable path sees either the previous +// tree or the new one, never neither (macOS APFS may fail such a racing lookup with EINVAL). +// It is not a snapshot for a reader walking the tree while it is swapped either. Callers +// therefore swap only while no plugin of the previous configuration runs (the agent does it +// between two configuration runs, after the reload drain), and serialize Activate with GC. It is a no-op when the tree is already active or the file system has no +// symlinks (plugins then receive dir itself). +func Activate(root, name, dir string) error { + bundleDir := filepath.Join(root, name) + versionDir := filepath.Dir(filepath.Clean(dir)) + if filepath.Base(filepath.Clean(dir)) != treeDir || filepath.Dir(versionDir) != bundleDir { + return fmt.Errorf("activate %s: %s is not a materialized tree of the bundle", name, dir) + } + if !symlinksSupported(root) { + return nil + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return fmt.Errorf("activate %s: the materialized tree %s is missing", name, dir) + } + target := filepath.Base(versionDir) + link := filepath.Join(bundleDir, currentLink) + if cur, err := os.Readlink(link); err == nil && cur == target { + return nil + } + tmp := filepath.Join(bundleDir, currentTmpPrefix+randomSuffix()) + if err := os.Symlink(target, tmp); err != nil { + return fmt.Errorf("activate %s: %w", name, err) + } + if err := os.Rename(tmp, link); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("activate %s: %w", name, err) + } + return nil +} + +// GC removes materialized directories under root except those in keep (Materialized.Dir +// values, or their parent), the one each bundle's current symlink points to, and the +// perBundle newest per bundle, plus abandoned temporary entries. Callers serialize GC with +// Activate. func GC(root string, keep map[string]struct{}, perBundle int) error { bundles, err := os.ReadDir(root) if errors.Is(err, os.ErrNotExist) { @@ -357,6 +430,10 @@ func GC(root string, keep map[string]struct{}, perBundle int) error { errs = append(errs, err) continue } + active := "" + if target, err := os.Readlink(filepath.Join(bundleDir, currentLink)); err == nil { + active = filepath.Join(bundleDir, target) + } type dirInfo struct { path string mod int64 @@ -364,7 +441,7 @@ func GC(root string, keep map[string]struct{}, perBundle int) error { var dirs []dirInfo for _, e := range entries { p := filepath.Join(bundleDir, e.Name()) - if strings.HasPrefix(e.Name(), ".tmp-") { + if strings.HasPrefix(e.Name(), tmpPrefix) || strings.HasPrefix(e.Name(), currentTmpPrefix) { errs = append(errs, os.RemoveAll(p)) continue } @@ -380,9 +457,15 @@ func GC(root string, keep map[string]struct{}, perBundle int) error { sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod > dirs[j].mod }) kept := 0 for _, d := range dirs { + if d.path == active { + continue + } if _, ok := keep[d.path]; ok { continue } + if _, ok := keep[filepath.Join(d.path, treeDir)]; ok { + continue + } if kept < perBundle { kept++ continue From 33b12dd67c6766fbe02d08c30ff335403d272d5c Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:56:58 -0300 Subject: [PATCH 16/47] feat(reconciler): stable inline paths, policy tree artifacts, duplicate packages (R62, R66, R67) R67: plugins receive each inline bundle's stable path; the runtime keeps the materialized trees separately (inlineTrees), and the candidate identity still hashes the trees, so any tree change restarts plugins. The run loop points the stable paths at a candidate's trees in start(), only after the previous configuration's run returned (after the reload drain), and back at the fallback's trees when it falls back; a failure to activate is a run failure. GC and activation share a lock, and GC keeps the trees of the running, pending, starting and fallback candidates as well as whatever current points to. R62: the reconciler and every plugin run share one artifact uploader (remoteAPI.UploadArtifact). At report time, so outside mode off and only for the candidate that runs, it uploads each reported tree (inline bundles, the trees they extend, OCI and local sources) once, memoized by tree digest per API and bounded by the remote request timeout, and fills artifact-digest on the bundle and its extends. Failures leave the field empty and never reject or fail; a tree the API refused for good is not retried, a transient failure is retried with the next report, a local tree changed since its inventory is not uploaded under the old digest. The field survives report truncation. R66: a plugin using an inline bundle that loads a policy package from two of its policy paths gets a warning naming both paths. Policy errors are de-duplicated before they are reported: exact repeats across plugins, and an API-side warning superseded by an agent error for the same bundle, path and code. Co-Authored-By: Claude Opus 5.5 --- cmd/agent.go | 24 ++- cmd/artifacts.go | 142 ++++++++++++++++ cmd/artifacts_test.go | 199 +++++++++++++++++++++++ cmd/inline.go | 217 +++++++++++++++++++++---- cmd/inline_test.go | 120 +++++++++++++- cmd/reconciler.go | 110 ++++++++++--- cmd/remote_test.go | 32 +++- cmd/report.go | 3 +- docs/adr/0003-remote-config-overlay.md | 34 ++++ docs/configuration.md | 36 +++- 10 files changed, 854 insertions(+), 63 deletions(-) create mode 100644 cmd/artifacts.go create mode 100644 cmd/artifacts_test.go diff --git a/cmd/agent.go b/cmd/agent.go index 68f1d83..9c46cbe 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -92,8 +92,12 @@ type agentConfig struct { Plugins map[string]*agentPlugin `mapstructure:"plugins"` AgentEvidence *agentEvidenceConfig `mapstructure:"agent_evidence"` - // inlinePolicyDirs maps "inline:" policy entries to their materialized directory. + // inlinePolicyDirs maps "inline:" policy entries to the path plugins receive: the + // bundle's stable path, which the reconciler points at inlineTrees before each run (R67). inlinePolicyDirs map[string]string + // inlineTrees maps "inline:" policy entries to their materialized, content-addressed + // tree (inlinepolicy.Materialized.Dir). + inlineTrees map[string]string // 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] @@ -359,9 +363,13 @@ func agentRunner(cmd *cobra.Command, args []string) error { // 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)) + // One artifact uploader for the process: the reconciler's policy tree uploads and every + // plugin run's evidence artifacts share what each API already has (R62). + artifacts := runner.NewArtifactUploader() + ar := NewAgentRunner(WithInstanceID(id), WithSharedArtifactUploader(artifacts)) rc := newReconciler(cmd, configPath, store, ar, logger) rc.instanceID = id + rc.artifacts = artifacts rc.resolvePolicy = func(ctx context.Context, source string) (string, error) { return ar.downloadPolicy(ctx, source, logger) } @@ -446,6 +454,8 @@ type AgentRunner struct { // instanceID is this agent instance's stable ID (R31); set once at construction. instanceID uuid.UUID + // artifacts is the process-wide artifact uploader, shared with the reconciler (R62). + artifacts *runner.ArtifactUploader // 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 @@ -462,6 +472,11 @@ func WithInstanceID(id uuid.UUID) AgentRunnerOption { return func(ar *AgentRunner) { ar.instanceID = id } } +// WithSharedArtifactUploader makes every plugin run upload through u (R62). +func WithSharedArtifactUploader(u *runner.ArtifactUploader) AgentRunnerOption { + return func(ar *AgentRunner) { ar.artifacts = u } +} + func NewAgentRunner(opts ...AgentRunnerOption) *AgentRunner { ar := &AgentRunner{ pluginLocations: map[string]string{}, @@ -472,6 +487,7 @@ func NewAgentRunner(opts ...AgentRunnerOption) *AgentRunner { httpClient: http.DefaultClient, instanceID: uuid.New(), protocolCache: map[string]int32{}, + artifacts: runner.NewArtifactUploader(), } for _, opt := range opts { opt(ar) @@ -1444,7 +1460,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { "auth_enabled", hasAPIAuth(config), "client_id", apiClientID(config), ) - resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths), runner.WithEvidenceProps(configRevisionProps(config)...)) + resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths), runner.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...)) policyBehaviorProto := policyBehaviorToProto(pluginConfig.PolicyBehavior) if err := initRunner(pluginName, pluginConfig.ProtocolVersion, runnerInstance, policyPaths, policyBehaviorProto, resultsHelper); err != nil { @@ -1587,7 +1603,7 @@ func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name "auth_enabled", hasAPIAuth(config), "client_id", apiClientID(config), ) - resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), runner.WithEvidenceProps(configRevisionProps(config)...)) + resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), runner.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...)) policyBehaviorProto := policyBehaviorToProto(plugin.PolicyBehavior) if err := initRunner(name, plugin.ProtocolVersion, runnerInstance, policyPaths, policyBehaviorProto, resultsHelper); err != nil { diff --git a/cmd/artifacts.go b/cmd/artifacts.go new file mode 100644 index 0000000..384a9c2 --- /dev/null +++ b/cmd/artifacts.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "net/http" + "regexp" + + "github.com/compliance-framework/agent/internal/policytree" + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" +) + +// Policy sources through the artifact store (R62). The report names every policy tree the +// agent runs (inline bundles, the vendor trees they extend, OCI and local sources) by tree +// digest. The agent also uploads each tree as a policy bundle artifact, through the same +// process-wide uploader the plugins' evidence uses, and reports the artifact digest next to +// the tree digest, so the UI can read the sources (GET /api/artifacts/{digest}/files). +// +// Uploads are best effort: a failure leaves artifact-digest empty and never rejects or fails +// a revision. They happen at report time, so only in report and apply modes, only for the +// candidate that runs (its checks passed), within remoteRequestTimeout per report. + +// artifactMemoLimit bounds the tree digest -> artifact digest memo. +const artifactMemoLimit = 1024 + +// artifactDigestPattern is the format of an artifact digest (the API checks the same). +var artifactDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// artifactTree is a policy tree the report names, and the directory it was read from. +type artifactTree struct { + digest string // agentconfig.BundleTreeDigest of the tree + dir string +} + +// uploadArtifacts uploads the trees of c that have no artifact yet. Each tree is uploaded +// once per process (memoized by tree digest); a tree the API refused for good is not retried. +func (rc *reconciler) uploadArtifacts(ctx context.Context, c *candidate) { + if rc.remote == nil || c == nil || len(c.trees) == 0 { + return + } + var pending []artifactTree + for _, t := range c.trees { + if _, done := rc.artifactMemo[t.digest]; !done && t.dir != "" { + pending = append(pending, t) + } + } + if len(pending) == 0 { + return + } + ctx, cancel := context.WithTimeout(ctx, remoteRequestTimeout) + defer cancel() + for _, t := range pending { + if _, done := rc.artifactMemo[t.digest]; done { + continue // the same tree twice in c + } + digest, err := rc.uploadTree(ctx, t) + var statusErr *sdk.ArtifactStatusError + switch { + case err == nil: + rc.rememberArtifact(t.digest, digest) + case errors.Is(err, runner.ErrArtifactsUnsupported): + if rc.logOnce("artifacts:unsupported") { + rc.logger.Info("The API does not support policy artifacts; the report names policy trees without their sources") + } + return + case ctx.Err() != nil: + if rc.logOnce("artifacts:timeout") { + rc.logger.Warn("Uploading policy trees as artifacts timed out; retrying with the next report", "timeout", remoteRequestTimeout) + } + return + case errors.As(err, &statusErr) && permanentArtifactFailure(statusErr.StatusCode): + rc.rememberArtifact(t.digest, "") + if rc.logOnce("artifacts:" + t.digest) { + rc.logger.Warn("The API refused a policy tree artifact; the report names it without its sources", "tree", t.digest, "status", statusErr.StatusCode, "error", err) + } + default: + if rc.logOnce("artifacts:" + t.digest) { + rc.logger.Warn("Could not upload a policy tree artifact; retrying with the next report", "tree", t.digest, "error", err) + } + } + } +} + +// uploadTree archives t exactly as the plugins' API helper archives a policy path, so both +// get the same artifact. A tree that changed on disk since it was inventoried (a local +// source edited in place) is not uploaded under the old digest. +func (rc *reconciler) uploadTree(ctx context.Context, t artifactTree) (string, error) { + files, _, err := policytree.ReadTree(t.dir) + if err != nil { + return "", err + } + if got := agentconfig.BundleTreeDigest(files); got != t.digest { + return "", fmt.Errorf("the policy tree at %s changed since it was inventoried (%s, now %s)", t.dir, t.digest, got) + } + tarball, err := policytree.TarFiles(files) + if err != nil { + return "", err + } + digest, err := rc.remote.UploadArtifact(ctx, sdk.ArtifactMediaTypePolicyBundle, tarball) + if err != nil { + return "", err + } + if !artifactDigestPattern.MatchString(digest) { + return "", fmt.Errorf("the API returned an invalid artifact digest %q", digest) + } + return digest, nil +} + +// permanentArtifactFailure reports whether an upload status means the API will never take +// this content (too large, invalid). 404/405 (no artifact support), 408 and 429 are not. +func permanentArtifactFailure(status int) bool { + switch status { + case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestTimeout, http.StatusTooManyRequests: + return false + } + return status >= 400 && status < 500 +} + +func (rc *reconciler) rememberArtifact(tree, artifact string) { + if len(rc.artifactMemo) >= artifactMemoLimit { + rc.artifactMemo = map[string]string{} + } + rc.artifactMemo[tree] = artifact +} + +// withArtifactDigests returns bundles with the artifact digests known for their trees. It +// copies what it changes: the candidate's reports are shared. +func (rc *reconciler) withArtifactDigests(bundles []agentconfig.PolicyBundleReport) []agentconfig.PolicyBundleReport { + out := append([]agentconfig.PolicyBundleReport(nil), bundles...) + for i := range out { + out[i].ArtifactDigest = rc.artifactMemo[out[i].Digest] + if out[i].Extends != nil { + ext := *out[i].Extends + ext.ArtifactDigest = rc.artifactMemo[ext.Digest] + out[i].Extends = &ext + } + } + return out +} diff --git a/cmd/artifacts_test.go b/cmd/artifacts_test.go new file mode 100644 index 0000000..6b7310c --- /dev/null +++ b/cmd/artifacts_test.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/compliance-framework/agent/internal/policytree" + "github.com/compliance-framework/agent/runner" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/sdk" +) + +func tarDigest(t *testing.T, dir string) string { + t.Helper() + tarball, err := policytree.TarDirectory(dir) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(tarball) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// TestArtifacts_ReportNamesTheSources_R62: the report carries the artifact digest of the +// inline tree and of the vendor tree it extends, and they are the digests a plugin's +// evaluation-time upload of the same directories produces. +func TestArtifacts_ReportNamesTheSources_R62(t *testing.T) { + h, vendor := newInlineHarness(t) + active := mustStartup(t, h.rc) + + r := h.remote.lastReport(t) + if len(r.PolicyBundles) != 1 || r.PolicyBundles[0].Extends == nil { + t.Fatalf("unexpected policy-bundles %+v", r.PolicyBundles) + } + b := r.PolicyBundles[0] + if want := tarDigest(t, active.runtime.inlinePolicyDirs["inline:ssh"]); b.ArtifactDigest != want { + t.Fatalf("inline artifact-digest = %q, want the digest of the stable path's tree %q", b.ArtifactDigest, want) + } + if want := tarDigest(t, vendor); b.Extends.ArtifactDigest != want { + t.Fatalf("extends artifact-digest = %q, want %q", b.Extends.ArtifactDigest, want) + } + if n := h.remote.uploadCount(); n != 2 { + t.Fatalf("expected one upload per tree, got %d", n) + } + + // Memoized: later reports upload nothing. + h.clock.Advance(reportResendInterval + 1) + h.rc.maybeReport(context.Background(), active, nil) + if n := h.remote.uploadCount(); n != 2 { + t.Fatalf("trees must be uploaded once, got %d uploads", n) + } + + // The digests survive dropping the file lists to fit the report size. + _, _, err := fitReport(&r, true) + if err != nil { + t.Fatal(err) + } + if len(r.PolicyBundles[0].Files) != 0 || r.PolicyBundles[0].ArtifactDigest != b.ArtifactDigest || r.PolicyBundles[0].Extends.ArtifactDigest != b.Extends.ArtifactDigest { + t.Fatalf("truncation must keep artifact-digest: %+v", r.PolicyBundles[0]) + } +} + +func TestArtifacts_SourcesAreUploaded(t *testing.T) { + local := t.TempDir() + if err := os.WriteFile(filepath.Join(local, "p.rego"), []byte("package compliance_framework.p\n\ntitle := \"p\"\n"), 0o644); err != nil { + t.Fatal(err) + } + h := newRemoteHarness(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["inline:ssh", "`+local+`"]`, 1)) + h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { + switch source { + case "ghcr.io/vendor/policies:v1": + return local, nil + case local: + return local, nil + } + return "", errors.New("unknown source " + source) + } + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + var found bool + for _, b := range r.PolicyBundles { + if b.Source == local { + found = true + if want := tarDigest(t, local); b.ArtifactDigest != want { + t.Fatalf("local source artifact-digest = %q, want %q", b.ArtifactDigest, want) + } + } + } + if !found { + t.Fatalf("the local source is not reported: %+v", r.PolicyBundles) + } +} + +// TestArtifacts_FailuresNeverRejectOrFail: an upload failure leaves artifact-digest empty; +// the revision applies, and a refused tree is not retried. +func TestArtifacts_FailuresNeverRejectOrFail(t *testing.T) { + for name, tc := range map[string]struct { + err error + wantRetried bool + }{ + "old API": {err: runner.ErrArtifactsUnsupported}, + "too large": {err: &sdk.ArtifactStatusError{StatusCode: http.StatusRequestEntityTooLarge}}, + "invalid": {err: &sdk.ArtifactStatusError{StatusCode: http.StatusBadRequest}}, + "rate limited": {err: &sdk.ArtifactStatusError{StatusCode: http.StatusTooManyRequests}, wantRetried: true}, + "transport error": {err: errors.New("connection reset"), wantRetried: true}, + "timeout": {err: context.DeadlineExceeded, wantRetried: true}, + } { + t.Run(name, func(t *testing.T) { + h, _ := newInlineHarness(t) + failing := true + h.remote.uploadErr = func(int) error { + if failing { + return tc.err + } + return nil + } + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if r.Status == agentconfig.StatusRejected || r.Status == agentconfig.StatusFailed { + t.Fatalf("an artifact failure must not reject or fail: %s/%s", r.Status, r.Reason) + } + if r.PolicyBundles[0].ArtifactDigest != "" || r.PolicyBundles[0].Extends.ArtifactDigest != "" { + t.Fatalf("a failed upload must leave artifact-digest empty: %+v", r.PolicyBundles[0]) + } + + failing = false + before := h.remote.uploadCount() + h.clock.Advance(reportResendInterval + 1) + h.rc.maybeReport(context.Background(), active, nil) + retried := h.remote.uploadCount() > before + if errors.Is(tc.err, runner.ErrArtifactsUnsupported) { + // The shared uploader owns the old-API backoff; the reconciler retries on + // the next report and the uploader answers without a request. + return + } + if retried != tc.wantRetried { + t.Fatalf("retried = %v, want %v", retried, tc.wantRetried) + } + if tc.wantRetried && h.remote.lastReport(t).PolicyBundles[0].ArtifactDigest == "" { + t.Fatal("a retried upload must fill artifact-digest") + } + }) + } +} + +// TestArtifacts_ModeOffUploadsNothing: no report, no upload. +func TestArtifacts_ModeOffUploadsNothing(t *testing.T) { + h, vendor := newInlineHarness(t) + h.writeConfig(t, strings.Replace(inlineBaseConfig, "mode: apply_safe", `mode: "off"`, 1)) + h.rc = h.newReconciler() + h.rc.resolvePolicy = func(context.Context, string) (string, error) { return vendor, nil } + mustStartup(t, h.rc) + if n := h.remote.uploadCount(); n != 0 { + t.Fatalf("mode off must not upload, got %d", n) + } +} + +// TestArtifacts_SharedUploaderDedupesWithEvaluation: the reconciler and a plugin's API +// helper share the process-wide uploader, so a tree the reconciler uploaded is not uploaded +// again when the plugin's evidence references it. +func TestArtifacts_SharedUploaderDedupesWithEvaluation(t *testing.T) { + var uploads int + client := &countingArtifacts{n: &uploads} + shared := runner.NewArtifactUploader() + remote := sdkRemote{artifacts: shared.Endpoint("http://api.test", client)} + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "p.rego"), []byte("package compliance_framework.p\n"), 0o644); err != nil { + t.Fatal(err) + } + tarball, err := policytree.TarDirectory(dir) + if err != nil { + t.Fatal(err) + } + for range 2 { + if _, err := remote.UploadArtifact(context.Background(), sdk.ArtifactMediaTypePolicyBundle, tarball); err != nil { + t.Fatal(err) + } + if _, err := shared.Endpoint("http://api.test", client).Upload(context.Background(), sdk.ArtifactMediaTypePolicyBundle, tarball); err != nil { + t.Fatal(err) + } + } + if uploads != 1 { + t.Fatalf("expected one upload, got %d", uploads) + } +} + +type countingArtifacts struct{ n *int } + +func (c *countingArtifacts) Upload(_ context.Context, mediaType string, content []byte) (*sdk.ArtifactInfo, error) { + *c.n++ + sum := sha256.Sum256(content) + return &sdk.ArtifactInfo{Digest: "sha256:" + hex.EncodeToString(sum[:]), MediaType: mediaType}, nil +} diff --git a/cmd/inline.go b/cmd/inline.go index e780b4e..ca72521 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -3,12 +3,15 @@ package cmd import ( "context" "errors" + "fmt" "path/filepath" "sort" + "strings" "github.com/compliance-framework/agent/internal/inlinepolicy" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/pkg/agentconfig/regocheck" + "github.com/compliance-framework/api/pkg/policyeval" ) // inlineGCKeepPerBundle is how many materialized versions of each bundle GC keeps besides the @@ -107,23 +110,133 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co return res, policyRejection(append(problems, res.warnings...)) } res.warnings = append(res.warnings, problems...) + res.warnings = append(res.warnings, rc.duplicatePackages(ctx, resolved, skip, materialized)...) + res.warnings = dedupePolicyErrors(res.warnings) agentconfig.SortPolicyErrors(res.warnings) res.dirs = map[string]string{} + res.trees = map[string]string{} for _, name := range sortedMaterializedKeys(materialized) { m := materialized[name] - res.dirs[agentconfig.InlineSourcePrefix+name] = m.Dir + entry := agentconfig.InlineSourcePrefix + name + res.dirs[entry] = m.Path + res.trees[entry] = m.Dir res.reports = append(res.reports, agentconfig.PolicyBundleReport{ - Source: agentconfig.InlineSourcePrefix + name, + Source: entry, Digest: m.Digest, Extends: m.Extends, Files: m.Files, }) + res.artifacts = append(res.artifacts, artifactTree{digest: m.Digest, dir: m.Dir}) + if m.Extends != nil { + res.artifacts = append(res.artifacts, artifactTree{digest: m.Extends.Digest, dir: m.ExtendsDir}) + } } return res, nil } +// duplicatePackages warns when a plugin that uses an inline bundle loads the same policy +// package from two of its policy paths, typically a vendor source and an inline bundle that +// extends it without replacing it (R66): the plugin then records evidence for that package +// twice. A path that cannot be resolved here is skipped (prefetch reports it). +func (rc *reconciler) duplicatePackages(ctx context.Context, resolved agentconfig.Config, skip map[string]string, materialized map[string]*inlinepolicy.Materialized) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + for _, pluginName := range sortedPluginNames(resolved.Plugins) { + p := resolved.Plugins[pluginName] + if p == nil || !p.IsEnabled() || len(p.Policies) < 2 { + continue + } + if _, skipped := skip[pluginName]; skipped { + continue + } + type origin struct { + entry, bundle, file string + } + byPackage := map[string][]origin{} + usesInline := false + for _, e := range p.Policies { + var files []agentconfig.PolicyFileReport + bundle := "" + if name, ok := agentconfig.InlineBundleName(e); ok { + m := materialized[name] + if m == nil { + continue + } + usesInline, bundle, files = true, name, m.Files + } else { + _, r, err := rc.sourceInventory(ctx, string(e)) + if err != nil { + continue + } + files = r.Files + } + seen := map[string]bool{} + for _, f := range files { + if f.Package == "" || seen[f.Package] || !policyeval.IsPolicyPackage(f.Package) || policyeval.IsTestFile(f.Path) { + continue + } + seen[f.Package] = true + byPackage[f.Package] = append(byPackage[f.Package], origin{entry: string(e), bundle: bundle, file: f.Path}) + } + } + if !usesInline { + continue + } + for _, pkg := range sortedOriginKeys(byPackage) { + origins := byPackage[pkg] + if len(origins) < 2 { + continue + } + var at origin + entries := make([]string, len(origins)) + for i, o := range origins { + entries[i] = o.entry + if at.bundle == "" && o.bundle != "" { + at = o + } + } + out = append(out, agentconfig.PolicyError{ + Bundle: at.bundle, + Path: at.file, + Severity: agentconfig.SeverityWarning, + Code: codeDuplicatePolicyPackage, + Message: fmt.Sprintf("plugin %s: package %s is defined in more than one of its policy paths (%s), so the plugin records its evidence more than once; replace the extended source with the inline bundle instead of adding both", + pluginName, pkg, strings.Join(entries, ", ")), + }) + } + } + return out +} + +// codeDuplicatePolicyPackage is the PolicyError code of R66. +const codeDuplicatePolicyPackage = "duplicate-policy-package" + +// dedupePolicyErrors drops exact duplicates (the per-plugin checks of one bundle repeat +// plugin-independent findings) and a warning superseded by an error with the same bundle, +// path and code (the API's static check may only warn about what the agent, which sees the +// whole tree, rejects). +func dedupePolicyErrors(errs []agentconfig.PolicyError) []agentconfig.PolicyError { + type site struct{ bundle, path, code string } + isError := map[site]bool{} + for _, e := range errs { + if e.Code != "" && e.Severity == agentconfig.SeverityError { + isError[site{e.Bundle, e.Path, e.Code}] = true + } + } + seen := map[agentconfig.PolicyError]bool{} + out := make([]agentconfig.PolicyError, 0, len(errs)) + for _, e := range errs { + if seen[e] || (e.Severity == agentconfig.SeverityWarning && e.Code != "" && isError[site{e.Bundle, e.Path, e.Code}]) { + continue + } + seen[e] = true + out = append(out, e) + } + return out +} + func policyRejection(errs []agentconfig.PolicyError) *applyError { + errs = dedupePolicyErrors(errs) agentconfig.SortPolicyErrors(errs) var only []agentconfig.PolicyError for _, e := range errs { @@ -136,13 +249,9 @@ func policyRejection(errs []agentconfig.PolicyError) *applyError { return aerr } -// sourceReports inventories the non-inline policy paths of runtime for the report. OCI trees -// are memoized per (source, dir); local trees are re-read. -func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) []agentconfig.PolicyBundleReport { - resolve := rc.boundedResolver() - if resolve == nil { - return nil - } +// sourceReports inventories the non-inline policy paths of runtime for the report, with the +// trees to upload as artifacts. +func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) ([]agentconfig.PolicyBundleReport, []artifactTree) { sources := map[string]struct{}{} for _, p := range runtime.Plugins { for _, e := range p.Policies { @@ -151,28 +260,43 @@ func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) [ } } } - var out []agentconfig.PolicyBundleReport + var reports []agentconfig.PolicyBundleReport + var trees []artifactTree for _, source := range sortedSetKeys(sources) { - dir, err := resolve(ctx, source) - if err != nil { - continue - } - key := source + "\x00" + dir - if r, ok := rc.inventoryMemo[key]; ok { - out = append(out, r) - continue - } - digest, files, err := inlinepolicy.Inventory(dir) + dir, r, err := rc.sourceInventory(ctx, source) if err != nil { continue } - r := agentconfig.PolicyBundleReport{Source: source, Digest: digest, Files: files} - if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { - rc.inventoryMemo[key] = r - } - out = append(out, r) + reports = append(reports, r) + trees = append(trees, artifactTree{digest: r.Digest, dir: dir}) } - return out + return reports, trees +} + +// sourceInventory resolves an OCI or local policy source and inventories its tree. OCI trees +// are memoized per (source, dir); local trees are re-read. +func (rc *reconciler) sourceInventory(ctx context.Context, source string) (string, agentconfig.PolicyBundleReport, error) { + resolve := rc.boundedResolver() + if resolve == nil { + return "", agentconfig.PolicyBundleReport{}, errors.New("no policy resolver") + } + dir, err := resolve(ctx, source) + if err != nil { + return "", agentconfig.PolicyBundleReport{}, err + } + key := source + "\x00" + dir + if r, ok := rc.inventoryMemo[key]; ok { + return dir, r, nil + } + digest, files, err := inlinepolicy.Inventory(dir) + if err != nil { + return "", agentconfig.PolicyBundleReport{}, err + } + r := agentconfig.PolicyBundleReport{Source: source, Digest: digest, Files: files} + if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { + rc.inventoryMemo[key] = r + } + return dir, r, nil } // boundedResolver wraps resolvePolicy with prepareNetworkTimeout per call (nil when unset). @@ -192,14 +316,21 @@ func (rc *reconciler) afterStartup(active *candidate) { rc.gcInline(active) } -// gcInline removes materialized inline bundles that none of keep uses and that are not among -// the newest inlineGCKeepPerBundle per bundle. It runs after startup and after every swap -// (keep = the running, the replaced and the new pending candidate), so a long-running daemon -// does not accumulate one directory per revision. +// gcInline removes materialized inline bundles that none of keep, the running, pending, +// starting or fallback candidate, nor a bundle's current symlink uses, and that are not among +// the newest inlineGCKeepPerBundle per bundle. It runs after startup and after every swap, +// so a long-running daemon does not accumulate one directory per revision. It holds +// inlineMu, so it never races activateInline. func (rc *reconciler) gcInline(keep ...*candidate) { if !rc.store.Writable() { return } + rc.mu.Lock() + keep = append(keep, rc.active, rc.pending, rc.starting, rc.fallback) + rc.mu.Unlock() + + rc.inlineMu.Lock() + defer rc.inlineMu.Unlock() dirs := map[string]struct{}{} found := false for _, c := range keep { @@ -207,7 +338,7 @@ func (rc *reconciler) gcInline(keep ...*candidate) { continue } found = true - for _, dir := range c.runtime.inlinePolicyDirs { + for _, dir := range c.runtime.inlineTrees { dirs[dir] = struct{}{} } } @@ -219,6 +350,21 @@ func (rc *reconciler) gcInline(keep ...*candidate) { } } +// activateInline points the stable path of each inline bundle c uses at c's tree (R67). +func (rc *reconciler) activateInline(c *candidate) error { + if c == nil || c.runtime == nil || len(c.runtime.inlineTrees) == 0 { + return nil + } + rc.inlineMu.Lock() + defer rc.inlineMu.Unlock() + var errs []error + for _, entry := range sortedStringKeys(c.runtime.inlineTrees) { + name := strings.TrimPrefix(entry, agentconfig.InlineSourcePrefix) + errs = append(errs, inlinepolicy.Activate(rc.inlineRoot(), name, c.runtime.inlineTrees[entry])) + } + return errors.Join(errs...) +} + func sortedBoolKeys(m map[string]bool) []string { keys := make([]string, 0, len(m)) for k := range m { @@ -245,3 +391,12 @@ func sortedPluginNames(m map[string]*agentconfig.Plugin) []string { sort.Strings(keys) return keys } + +func sortedOriginKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/cmd/inline_test.go b/cmd/inline_test.go index a594b2d..0459ca0 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -144,7 +144,123 @@ func TestInline_GCAfterSwap(t *testing.T) { if err != nil { t.Fatal(err) } - if len(entries) > inlineGCKeepPerBundle+2 { - t.Fatalf("expected at most %d materialized dirs after GC, found %d", inlineGCKeepPerBundle+2, len(entries)) + dirs := 0 + for _, e := range entries { + if e.IsDir() { + dirs++ + } + } + if dirs > inlineGCKeepPerBundle+2 { + t.Fatalf("expected at most %d materialized dirs after GC, found %d", inlineGCKeepPerBundle+2, dirs) + } + // The stable path points at the running tree. + running := h.rc.running().runtime + got, err := filepath.EvalSymlinks(running.inlinePolicyDirs["inline:ssh"]) + if err != nil { + t.Fatal(err) + } + want, _ := filepath.EvalSymlinks(running.inlineTrees["inline:ssh"]) + if got != want { + t.Fatalf("the stable path resolves to %s, want the running tree %s", got, want) + } +} + +// TestInline_DuplicatePackageAcrossPolicyPaths_R66: a plugin that loads both the vendor +// source and an inline bundle extending it gets a warning naming both paths; the revision +// still applies. +func TestInline_DuplicatePackageAcrossPolicyPaths_R66(t *testing.T) { + h, vendor := newInlineHarness(t) + h.writeConfig(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1", "inline:ssh"]`, 1)) + h.rc = h.newReconciler() + h.rc.resolvePolicy = func(context.Context, string) (string, error) { return vendor, nil } + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable { + t.Fatalf("a duplicate package is a warning only, got %s/%s", r.Status, r.Reason) + } + var found bool + for _, e := range r.PolicyErrors { + if e.Code == codeDuplicatePolicyPackage { + found = e.Severity == agentconfig.SeverityWarning && e.Bundle == "ssh" && e.Path == "banner.rego" && + strings.Contains(e.Message, "compliance_framework.banner") && + strings.Contains(e.Message, "ghcr.io/vendor/policies:v1") && strings.Contains(e.Message, "inline:ssh") + } + } + if !found { + t.Fatalf("expected a duplicate-policy-package warning naming both paths, got %+v", r.PolicyErrors) + } + + // Replacing the source with the inline bundle (the R22 swap) clears it. + h, _ = newInlineHarness(t) + mustStartup(t, h.rc) + for _, e := range h.remote.lastReport(t).PolicyErrors { + if e.Code == codeDuplicatePolicyPackage { + t.Fatalf("no warning expected without the duplicate path: %+v", e) + } + } +} + +// TestInline_StablePathSwapsOnlyBetweenRuns_R67: a revision prepared while a run is in +// progress does not move the stable path under it; the run loop points it at the new tree +// before the next run, and back at the previous tree when it falls back. +func TestInline_StablePathSwapsOnlyBetweenRuns_R67(t *testing.T) { + h, _ := newInlineHarness(t) + h.remote.publish(0, `{}`) + active, err := h.rc.startup(context.Background()) + if err != nil { + t.Fatal(err) + } + stable := active.runtime.inlinePolicyDirs["inline:ssh"] + resolve := func() string { + t.Helper() + got, err := filepath.EvalSymlinks(stable) + if err != nil { + t.Fatal(err) + } + return got + } + treeOf := func(cfg *agentConfig) string { + got, _ := filepath.EvalSymlinks(cfg.inlineTrees["inline:ssh"]) + return got + } + + var runs []string + var firstTree string + errStop := errors.New("stop") + err = h.rc.run(active, func(ctx context.Context, cfg *agentConfig) error { + if cfg.inlinePolicyDirs["inline:ssh"] != stable { + t.Errorf("plugins must always get the stable path, got %s", cfg.inlinePolicyDirs["inline:ssh"]) + } + if resolve() != treeOf(cfg) { + t.Errorf("run %d: the stable path does not point at the run's tree", len(runs)+1) + } + runs = append(runs, treeOf(cfg)) + switch len(runs) { + case 1: + firstTree = resolve() + // A new revision arrives and is prepared while this run is in progress. + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\ntitle := \"extra v2\"\n"}}}}`) + h.rc.reconcile(context.Background(), triggerPoll) + if ctx.Err() == nil { + t.Error("the swap must cancel the running configuration") + } + if resolve() != firstTree { + t.Error("the stable path moved while the previous configuration was still running") + } + return nil + case 2: + if resolve() == firstTree { + t.Error("the new run must see the new tree") + } + return errStop // fall back to the first configuration + default: + if resolve() != firstTree { + t.Error("the fallback must point the stable path back at its tree") + } + return errStop + } + }) + if !errors.Is(err, errStop) || len(runs) != 3 { + t.Fatalf("run returned %v after %d runs", err, len(runs)) } } diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 75cc864..f830731 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -17,6 +17,7 @@ import ( "github.com/compliance-framework/agent/internal/agentstate" "github.com/compliance-framework/agent/internal/inlinepolicy" + runnerpkg "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" "github.com/fsnotify/fsnotify" @@ -56,8 +57,10 @@ type candidate struct { 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 - bundles []agentconfig.PolicyBundleReport + identity string + bundles []agentconfig.PolicyBundleReport + // trees are the policy trees bundles describe, uploaded as artifacts for the report (R62). + trees []artifactTree warnings []agentconfig.FieldError // R34 file-origin warnings policyWarnings []agentconfig.PolicyError // G3b severity=warning } @@ -119,14 +122,28 @@ type configReporter interface { Report(ctx context.Context, instanceID uuid.UUID, r agentconfig.Report) error } -// remoteAPI bundles the two remote configuration calls. +// artifactUploader is the test seam over the shared artifact uploader (R62). +type artifactUploader interface { + UploadArtifact(ctx context.Context, mediaType string, content []byte) (string, error) +} + +// remoteAPI bundles the remote configuration calls and the artifact upload. type remoteAPI interface { overlayFetcher configReporter + artifactUploader } -// sdkRemote adapts the SDK client to remoteAPI. -type sdkRemote struct{ client *sdk.Client } +// sdkRemote adapts the SDK client to remoteAPI. Artifacts go through the process-wide +// uploader, shared with the plugins' API helpers. +type sdkRemote struct { + client *sdk.Client + artifacts *runnerpkg.ArtifactEndpoint +} + +func (s sdkRemote) UploadArtifact(ctx context.Context, mediaType string, content []byte) (string, error) { + return s.artifacts.Upload(ctx, mediaType, content) +} func (s sdkRemote) Get(ctx context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) { return s.client.AgentConfig.Get(ctx, ifNoneMatch) @@ -137,7 +154,7 @@ func (s sdkRemote) Report(ctx context.Context, instanceID uuid.UUID, r agentconf } // newSDKRemote builds the remote configuration client from the (locked, file-only) api block. -func newSDKRemote(c agentconfig.Config) remoteAPI { +func newSDKRemote(c agentconfig.Config, uploader *runnerpkg.ArtifactUploader) remoteAPI { if c.API == nil { return nil } @@ -148,7 +165,8 @@ func newSDKRemote(c agentconfig.Config) remoteAPI { ClientSecret: strings.TrimSpace(c.API.Auth.ClientSecret), } } - return sdkRemote{client: sdk.NewClient(nil, cfg)} + client := sdk.NewClient(nil, cfg) + return sdkRemote{client: client, artifacts: uploader.Endpoint(cfg.BaseURL, client.Artifact)} } // runFunc runs one configuration until it is cancelled (daemon) or completes (one-shot). @@ -188,12 +206,23 @@ type reconciler struct { resolvePolicy inlinepolicy.Resolver // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"). inventoryMemo map[string]agentconfig.PolicyBundleReport - now func() time.Time - - mu sync.Mutex // guards active, pending, cancelRun - active *candidate - pending *candidate + // artifacts is the process-wide artifact uploader (shared with the plugins' API helpers). + artifacts *runnerpkg.ArtifactUploader + // artifactMemo maps a policy tree digest to the digest of its uploaded artifact ("" when + // the API refused it for good). Owned by the reconciler goroutine. + artifactMemo map[string]string + now func() time.Time + + mu sync.Mutex // guards active, pending, starting, fallback, cancelRun + active *candidate + pending *candidate + // starting is the candidate the run loop took from pending and is about to bind; + // fallback is the one it falls back to if the running one fails. GC keeps their trees. + starting *candidate + fallback *candidate cancelRun context.CancelFunc + // inlineMu serializes activating inline trees (run loop) with GC (reconciler goroutine). + inlineMu sync.Mutex // Everything below is owned by the reconciler goroutine (startup runs before loop). base *baseSnapshot @@ -220,7 +249,7 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor if logger == nil { logger = hclog.NewNullLogger() } - return &reconciler{ + rc := &reconciler{ cmd: cmd, configPath: configPath, store: store, @@ -229,13 +258,16 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor fileEvents: make(chan struct{}, 1), runFailed: make(chan *candidate, 1), debounce: 500 * time.Millisecond, - newRemote: newSDKRemote, lookupEnv: os.LookupEnv, now: time.Now, loggedOnce: map[string]bool{}, inventoryMemo: map[string]agentconfig.PolicyBundleReport{}, + artifacts: runnerpkg.NewArtifactUploader(), + artifactMemo: map[string]string{}, } + rc.newRemote = func(c agentconfig.Config) remoteAPI { return newSDKRemote(c, rc.artifacts) } + return rc } func (rc *reconciler) rcfg() agentconfig.RemoteConfig { @@ -268,6 +300,8 @@ func (rc *reconciler) setBase(base *baseSnapshot) { } rc.remoteKey = key rc.fetchBackoffUntil, rc.reportBackoffUntil = time.Time{}, time.Time{} + // Artifacts uploaded to the previous API are not in this one. + rc.artifactMemo = map[string]string{} } id := cacheIdentity(base.declared) @@ -558,6 +592,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent if err != nil { return nil, failed(agentconfig.ReasonInvalidConfig, err) } + runtime.inlineTrees = inline.trees prefetchCtx, cancelPrefetch := context.WithTimeout(ctx, prepareNetworkTimeout) err = rc.runner.Prefetch(prefetchCtx, runtime) cancelPrefetch() @@ -567,7 +602,9 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent return nil, aerr } if rcfg.Mode != agentconfig.ModeOff { - inline.reports = append(inline.reports, rc.sourceReports(ctx, runtime)...) + reports, trees := rc.sourceReports(ctx, runtime) + inline.reports = append(inline.reports, reports...) + inline.artifacts = append(inline.artifacts, trees...) } // The digest is over the UNRESOLVED form with the same masking as the reported effective @@ -584,8 +621,9 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent declared: declared, runtime: runtime, digest: digest, - identity: candidateIdentity(declared, inline.dirs), + identity: candidateIdentity(declared, inline.trees), bundles: inline.reports, + trees: inline.artifacts, warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), policyWarnings: inline.warnings, }, nil @@ -593,9 +631,11 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent // inlineResult is what the inline bundle step contributes to a candidate (G3b). type inlineResult struct { - dirs map[string]string - reports []agentconfig.PolicyBundleReport - warnings []agentconfig.PolicyError + dirs map[string]string // "inline:" -> the stable path plugins receive + trees map[string]string // "inline:" -> the materialized tree + reports []agentconfig.PolicyBundleReport + artifacts []artifactTree + warnings []agentconfig.PolicyError } // overlayTouched returns the pointers an overlay changed, computed on the unresolved forms so @@ -652,6 +692,8 @@ func overlayValidationError(err error) *applyError { return rejected(reason, errs) } +// candidateIdentity hashes the declared config and the materialized inline trees (not the +// stable paths, which never change), so a different tree is a different configuration. func candidateIdentity(c agentconfig.Config, inlineDirs map[string]string) string { raw, err := agentconfig.CanonicalJSON(struct { Config agentconfig.Config `json:"config"` @@ -670,6 +712,7 @@ func (rc *reconciler) bind(active *candidate, cancel context.CancelFunc) { rc.mu.Lock() defer rc.mu.Unlock() rc.active = active + rc.starting = nil rc.cancelRun = cancel if rc.pending != nil { cancel() @@ -730,9 +773,28 @@ func (rc *reconciler) takePending() *candidate { defer rc.mu.Unlock() next := rc.pending rc.pending = nil + if next != nil { + rc.starting = next + } return next } +// setFallback records the candidate the run loop falls back to (GC keeps its trees). +func (rc *reconciler) setFallback(c *candidate) { + rc.mu.Lock() + defer rc.mu.Unlock() + rc.fallback = c +} + +// start points the inline bundles' stable paths at c's trees, then records c as the running +// candidate. The run loop calls it only once the previous configuration's run returned, so +// the swap never happens under a running plugin (R67). +func (rc *reconciler) start(c *candidate, cancel context.CancelFunc) error { + err := rc.activateInline(c) + rc.bind(c, cancel) + return err +} + // current returns the candidate that is running, or about to run when a swap is pending. func (rc *reconciler) current() *candidate { rc.mu.Lock() @@ -749,14 +811,18 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { var previous, failedRun *candidate for { runCtx, cancel := context.WithCancel(context.Background()) - rc.bind(active, cancel) + runErr := rc.start(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) + if runErr != nil { + rc.logger.Error("Could not activate the inline policy bundles", "error", runErr) + } else { + runErr = run(runCtx, active.runtime) + } reload := runCtx.Err() != nil cancel() active = rc.record(active) @@ -765,6 +831,7 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { rc.logger.Error("Configuration failed to run; falling back to the previous configuration", "error", runErr) failedRun = active active, previous = previous, nil + rc.setFallback(nil) continue } return runErr @@ -777,6 +844,7 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { continue } previous, active = active, next + rc.setFallback(previous) } } diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 41309d7..24e06be 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -3,6 +3,8 @@ package cmd import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -31,6 +33,8 @@ type fakeRemote struct { reportErr func(n int, r agentconfig.Report) error gets []string reports []agentconfig.Report + uploads []string + uploadErr func(n int) error } func (f *fakeRemote) Get(_ context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) { @@ -62,6 +66,26 @@ func (f *fakeRemote) Report(_ context.Context, _ uuid.UUID, r agentconfig.Report return nil } +// UploadArtifact records an artifact upload; uploadErr scripts failures. +func (f *fakeRemote) UploadArtifact(_ context.Context, mediaType string, content []byte) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.uploads = append(f.uploads, mediaType) + if f.uploadErr != nil { + if err := f.uploadErr(len(f.uploads)); err != nil { + return "", err + } + } + sum := sha256.Sum256(content) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +func (f *fakeRemote) uploadCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.uploads) +} + // publish sets a new overlay revision with an opaque ETag. func (f *fakeRemote) publish(rev int64, overlay string) { f.mu.Lock() @@ -183,7 +207,9 @@ func mustStartup(t *testing.T, rc *reconciler) *candidate { if err != nil { t.Fatalf("startup: %v", err) } - rc.bind(active, func() {}) + if err := rc.start(active, func() {}); err != nil { + t.Fatalf("start: %v", err) + } return active } @@ -192,7 +218,9 @@ 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() {}) + if err := h.rc.start(next, func() {}); err != nil { + t.Fatalf("start: %v", err) + } } return h.rc.current() } diff --git a/cmd/report.go b/cmd/report.go index 8adc6cf..401ca2f 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -56,6 +56,7 @@ func (rc *reconciler) maybeReport(ctx context.Context, active *candidate, outcom if rc.now().Before(rc.reportBackoffUntil) { return } + rc.uploadArtifacts(ctx, active) report := rc.buildReport(active, outcome, rcfg) body, fingerprint, err := fitReport(&report, false) if err != nil { @@ -115,7 +116,7 @@ func (rc *reconciler) buildReport(active *candidate, outcome *applyError, rcfg a Base: marshalRaw(agentconfig.Redact(active.base.declared, opts...)), Effective: marshalRaw(agentconfig.Redact(active.declared, opts...)), EffectiveDigest: active.digest, - PolicyBundles: append([]agentconfig.PolicyBundleReport(nil), active.bundles...), + PolicyBundles: rc.withArtifactDigests(active.bundles), Warnings: active.warnings, RemoteConfig: &rcfg, } diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index ed1599a..68a2b83 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -62,6 +62,40 @@ executes on the agent host while a revision is checked (D17); a vendor test that Residual risk (R20): a vendor rule that already calls `http.send` with a URL taken from `data` makes `policy_data` edits to that plugin effectively able to direct its requests. Eval-time capabilities are a follow-up. +### Policy contract: the agent is authoritative (R63, R65, R66) + +The API's `regocheck` runs `policyeval.CheckContract` on the authored modules only (it lacks the extends trees). The +agent, after the compile and the tests pass, adds what needs the whole tree: the static check on the vendor-only +packages (warnings, never re-run on authored modules), and a dry run on an empty input through `policyeval.Execute` +and `policy-manager`'s `GetRiskTemplates`, sandboxed like the tests. Decode errors and `Result.Issues` become located +`PolicyError`s with the contract codes: errors for packages that contain an authored module, warnings for vendor-only +packages; conflicts that only show on `{}` and input-dependent titles are warnings. A package that fails to evaluate is +left out of the next attempt, so one broken package does not hide the others. A compile error in a vendor file whose +package an authored module also defines carries an override hint (R65). A package defined in two of a plugin's policy +paths is a warning naming both (R66). The per-plugin results are de-duplicated before they are reported. + +### Stable inline paths (R67) + +`policy-manager` seeds evidence UUIDs with the policy file path. Materialized bundles stay write-once, +content-addressed directories (`//bundle`), but plugins receive `/current/bundle`, where `current` +is a symlink swapped with an atomic rename. The symlink is an intermediate path component on purpose: OPA's bundle +loader does not descend into a symlinked root directory and would silently load nothing. The run loop swaps it only +between two configuration runs, after the reload drain, and on a fallback; a plugin run therefore sees one tree from +start to end, and its API helper resolves the path when the run starts, so evidence artifacts are the tree the run +evaluated. The candidate identity still hashes the digest directories, so any tree change restarts the plugins. GC and +the swap share a lock; GC keeps the trees of the running, pending, starting and fallback candidates and whatever +`current` points to. + +### One channel for policy sources (R62) + +The UI needs the vendor sources to pre-fill an override, and the API never sees them. Rather than a second route, the +agent reuses the evidence artifact store: one process-wide `runner.ArtifactUploader` is shared by the reconciler and +every plugin's API helper, and one archiver (`internal/policytree`: `ReadTree` + `TarFiles`) serves both, so the +configuration-time and evaluation-time uploads of a tree are the same bytes and the same artifact. At report time the +reconciler uploads each reported tree once (memoized by tree digest per API) within the remote request timeout and +fills `artifact-digest` on the bundle and its `extends`; the field survives report truncation. Failures leave it empty +and never reject or fail a revision. + ### Plugin environment filter go-plugin hands the whole host environment to plugins. The agent now sets `SkipHostEnv` and passes the host diff --git a/docs/configuration.md b/docs/configuration.md index d639b47..0a06af9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -239,7 +239,9 @@ policy_bundles: import rego.v1 - violation contains {"remarks": "too many"} if input.max_auth_tries > data.max_auth_tries + title := "SSH allows at most data.max_auth_tries authentication attempts" + + violation contains {"id": "too-many", "remarks": "too many"} if input.max_auth_tries > data.max_auth_tries data: # merge-patched into data.json allowed_ciphers: ["aes256-gcm@openssh.com"] ``` @@ -263,9 +265,39 @@ policy_bundles: helpers and `with ... as http.send`), and runs its Rego tests with the plugin's `policy_data`. A failing test that the bundle authored rejects the configuration; a failing vendor test is only a warning. Tests never run when a forbidden builtin is reachable, and they run sandboxed: a test that calls one of those builtins fails instead of executing it. +- **Policy contract (R63).** A `compliance_framework.*` package must produce what the agent needs to record evidence: + a string `title`, `violation` as a set of objects with string `id`/`title`/`description`/`remarks`, `labels` as a + map of strings, and valid `risk_templates`. The API checks the authored modules statically when an overlay is saved. + The agent, which sees the whole tree, also checks the vendor packages statically and then dry-runs every package + on an empty input (`{}` plus the plugin's `policy_data`), sandboxed, through the same calls a plugin makes. A + problem in a package that has an authored module (including an override) is an **error**; in a package only the + vendor defines it is a **warning**, as is an evaluation conflict that only shows on `{}` or a `title` that depends on + the input. So an override that leaves a package without a `title` is rejected: that package would record no + evidence. +- **Vendor tests that no longer compile (R65)** reject the revision: plugins compile `_test.rego` files too, so such a + bundle would produce no evidence at all. When the failing file is a vendor file in a package an authored module + also defines, the error carries a hint: keep the rule the override removed, or add the test to `delete`. +- **Duplicate evidence (R66).** When a plugin lists both a source and an inline bundle that `extends` it, every + vendor package is evaluated twice and recorded twice. The agent reports a warning naming both paths; replace the + source with the inline bundle instead. - **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any `.rego` file fails with `download-failed`. -- Inline bundles are written under the state directory (`/inline///`) and are never downloaded. +- **Where bundles live (R67).** Inline bundles are written under the state directory and are never downloaded. Each + revision of a bundle is a write-once directory named by its tree digest, `/inline///bundle/`, + but plugins always receive the same path, `/inline//current/bundle`: `current` is a symlink the agent + swaps atomically to the running revision's directory, between two configuration runs (never while a plugin of the + previous configuration runs). Evidence UUIDs are seeded with the policy file path, so an unchanged package keeps its + evidence identity across edits of the bundle. On a file system without symlinks (Windows without the privilege), + plugins receive the digest directory itself and evidence identity changes with each revision, as before. + Directories no running, pending or fallback configuration uses are garbage-collected, never the one `current` + points to. +- **Sources for the UI (R62).** Outside mode `off`, the agent uploads every policy tree it reports (each inline + bundle, the tree it extends, and each OCI or local source a plugin uses) as a policy bundle artifact, and reports + its `artifact-digest` next to the tree digest, so the UI can show and pre-fill vendor sources. These are the same + artifacts evidence references for playback: one uploader serves both, and a tree is uploaded once. Uploads are + best effort: a failure (an API without artifacts, a tree over the API's size limit, a timeout) leaves + `artifact-digest` empty and never rejects or fails a revision. Note that artifacts are readable with + `artifact:read`. ## Remote configuration From 37077364445fcb33444144b3a006b14addca997e Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:00:23 -0300 Subject: [PATCH 17/47] self-review: address round-2 pass 1 findings - run loop: start(c, fallback) sets the starting and fallback candidates together before activating, so GC keeps both trees throughout a swap and a fallback. - writeOnce: a completion marker next to bundle/; a pre-R67 directory whose vendor tree has a top-level bundle/ is rebuilt, not reused. - contract: a dry-run timeout is a warning (vendor packages are evaluated too); an authored test alone does not make a vendor package authored; the duplicate-module warning points at the authored file. - artifacts: a local tree edited since its inventory is not re-read on every poll. - R66 wording; sortedMapKeys. Co-Authored-By: Claude Opus 5.5 --- cmd/artifacts.go | 11 ++++++++++- cmd/inline.go | 6 +++--- cmd/reconciler.go | 22 +++++++++------------- cmd/remote_test.go | 4 ++-- internal/inlinepolicy/activate_test.go | 10 ++++++++++ internal/inlinepolicy/contract.go | 20 +++++++++++++++----- internal/inlinepolicy/contract_test.go | 9 +++++++++ internal/inlinepolicy/materialize.go | 25 ++++++++++++++++++++----- 8 files changed, 78 insertions(+), 29 deletions(-) diff --git a/cmd/artifacts.go b/cmd/artifacts.go index 384a9c2..b9df9ee 100644 --- a/cmd/artifacts.go +++ b/cmd/artifacts.go @@ -29,6 +29,9 @@ const artifactMemoLimit = 1024 // artifactDigestPattern is the format of an artifact digest (the API checks the same). var artifactDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +// errTreeChanged: a local tree no longer has the digest it was inventoried with. +var errTreeChanged = errors.New("the policy tree changed since it was inventoried") + // artifactTree is a policy tree the report names, and the directory it was read from. type artifactTree struct { digest string // agentconfig.BundleTreeDigest of the tree @@ -71,6 +74,12 @@ func (rc *reconciler) uploadArtifacts(ctx context.Context, c *candidate) { rc.logger.Warn("Uploading policy trees as artifacts timed out; retrying with the next report", "timeout", remoteRequestTimeout) } return + case errors.Is(err, errTreeChanged): + // The candidate's report is stale for this tree; do not re-read it every poll. + rc.rememberArtifact(t.digest, "") + if rc.logOnce("artifacts:" + t.digest) { + rc.logger.Warn("A policy tree changed on disk since it was inventoried; the report names it without its sources", "dir", t.dir, "error", err) + } case errors.As(err, &statusErr) && permanentArtifactFailure(statusErr.StatusCode): rc.rememberArtifact(t.digest, "") if rc.logOnce("artifacts:" + t.digest) { @@ -93,7 +102,7 @@ func (rc *reconciler) uploadTree(ctx context.Context, t artifactTree) (string, e return "", err } if got := agentconfig.BundleTreeDigest(files); got != t.digest { - return "", fmt.Errorf("the policy tree at %s changed since it was inventoried (%s, now %s)", t.dir, t.digest, got) + return "", fmt.Errorf("%w: %s was %s, now %s", errTreeChanged, t.dir, t.digest, got) } tarball, err := policytree.TarFiles(files) if err != nil { diff --git a/cmd/inline.go b/cmd/inline.go index ca72521..18debc0 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -182,7 +182,7 @@ func (rc *reconciler) duplicatePackages(ctx context.Context, resolved agentconfi if !usesInline { continue } - for _, pkg := range sortedOriginKeys(byPackage) { + for _, pkg := range sortedMapKeys(byPackage) { origins := byPackage[pkg] if len(origins) < 2 { continue @@ -200,7 +200,7 @@ func (rc *reconciler) duplicatePackages(ctx context.Context, resolved agentconfi Path: at.file, Severity: agentconfig.SeverityWarning, Code: codeDuplicatePolicyPackage, - Message: fmt.Sprintf("plugin %s: package %s is defined in more than one of its policy paths (%s), so the plugin records its evidence more than once; replace the extended source with the inline bundle instead of adding both", + Message: fmt.Sprintf("plugin %s: package %s is defined in more than one of its policy paths (%s), so the plugin records its evidence more than once; if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", pluginName, pkg, strings.Join(entries, ", ")), }) } @@ -392,7 +392,7 @@ func sortedPluginNames(m map[string]*agentconfig.Plugin) []string { return keys } -func sortedOriginKeys[V any](m map[string]V) []string { +func sortedMapKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) diff --git a/cmd/reconciler.go b/cmd/reconciler.go index f830731..d08e6b3 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -779,17 +779,15 @@ func (rc *reconciler) takePending() *candidate { return next } -// setFallback records the candidate the run loop falls back to (GC keeps its trees). -func (rc *reconciler) setFallback(c *candidate) { - rc.mu.Lock() - defer rc.mu.Unlock() - rc.fallback = c -} - // start points the inline bundles' stable paths at c's trees, then records c as the running -// candidate. The run loop calls it only once the previous configuration's run returned, so -// the swap never happens under a running plugin (R67). -func (rc *reconciler) start(c *candidate, cancel context.CancelFunc) error { +// candidate and fallback as the one to fall back to. The run loop calls it only once the +// previous configuration's run returned, so the swap never happens under a running plugin +// (R67). starting and fallback are set together first, so GC keeps the trees of both +// throughout (the old active stays covered until it becomes the fallback). +func (rc *reconciler) start(c, fallback *candidate, cancel context.CancelFunc) error { + rc.mu.Lock() + rc.starting, rc.fallback = c, fallback + rc.mu.Unlock() err := rc.activateInline(c) rc.bind(c, cancel) return err @@ -811,7 +809,7 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { var previous, failedRun *candidate for { runCtx, cancel := context.WithCancel(context.Background()) - runErr := rc.start(active, cancel) + runErr := rc.start(active, previous, cancel) if failedRun != nil { // Notify only once the fallback is bound, so the reconciler's current() is the // fallback, never the candidate that failed. @@ -831,7 +829,6 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { rc.logger.Error("Configuration failed to run; falling back to the previous configuration", "error", runErr) failedRun = active active, previous = previous, nil - rc.setFallback(nil) continue } return runErr @@ -844,7 +841,6 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { continue } previous, active = active, next - rc.setFallback(previous) } } diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 24e06be..b167c31 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -207,7 +207,7 @@ func mustStartup(t *testing.T, rc *reconciler) *candidate { if err != nil { t.Fatalf("startup: %v", err) } - if err := rc.start(active, func() {}); err != nil { + if err := rc.start(active, nil, func() {}); err != nil { t.Fatalf("start: %v", err) } return active @@ -218,7 +218,7 @@ func (h *remoteHarness) poll(t *testing.T) *candidate { t.Helper() h.rc.reconcile(context.Background(), triggerPoll) if next := h.rc.takePending(); next != nil { - if err := h.rc.start(next, func() {}); err != nil { + if err := h.rc.start(next, nil, func() {}); err != nil { t.Fatalf("start: %v", err) } } diff --git a/internal/inlinepolicy/activate_test.go b/internal/inlinepolicy/activate_test.go index e13f584..22ff4ec 100644 --- a/internal/inlinepolicy/activate_test.go +++ b/internal/inlinepolicy/activate_test.go @@ -238,6 +238,13 @@ func TestMaterialize_RebuildsOldLayout(t *testing.T) { if err := os.WriteFile(filepath.Join(version, "x.rego"), []byte("old"), 0o644); err != nil { t.Fatal(err) } + // A vendor tree with a top-level bundle/ directory must not pass for the new layout. + if err := os.MkdirAll(filepath.Join(version, "bundle"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(version, "bundle", "other.rego"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } again, err := Materialize(context.Background(), root, "ssh", b, nil) if err != nil { t.Fatal(err) @@ -245,4 +252,7 @@ func TestMaterialize_RebuildsOldLayout(t *testing.T) { if got := readFile(t, again.Dir, "x.rego"); got != b.Modules["x.rego"] { t.Fatalf("the old layout was not rebuilt: %q", got) } + if _, err := os.Stat(filepath.Join(again.Dir, "other.rego")); !os.IsNotExist(err) { + t.Fatal("a stale file of the old layout survived the rebuild") + } } diff --git a/internal/inlinepolicy/contract.go b/internal/inlinepolicy/contract.go index 7d50337..b367194 100644 --- a/internal/inlinepolicy/contract.go +++ b/internal/inlinepolicy/contract.go @@ -45,12 +45,13 @@ func packagesOf(modules map[string]*ast.Module, root string) treePackages { return out } -// authoredPackages returns the packages that contain an authored module (tests included: -// a test module adds its rules to the package plugins evaluate). +// authoredPackages returns the packages that contain an authored non-test module. An +// authored test alone does not make a vendor package authored: adding a test must not turn +// the vendor's contract debt into errors. func (p treePackages) authoredPackages(authored map[string]bool) map[string]bool { out := map[string]bool{} for file, pkg := range p { - if authored[file] { + if authored[file] && !policyeval.IsTestFile(file) { out[pkg] = true } } @@ -126,9 +127,16 @@ func staticContract(in CheckInput, modules map[string]*ast.Module, pkgs treePack if len(files) < 2 { continue } + at := files[0] + for _, f := range files { + if in.Authored[f] { + at = f + break + } + } out = append(out, agentconfig.PolicyError{ Bundle: in.Bundle, - Path: files[1], + Path: at, Message: fmt.Sprintf("package %s is defined by %d non-test modules (%s); plugins record evidence for the whole package once per module", pkg, len(files), strings.Join(files, ", ")), Severity: agentconfig.SeverityWarning, Code: policyeval.IssueDuplicatePackageModule, @@ -214,7 +222,9 @@ func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePacka failure := func(err error, code string) string { if ctx.Err() != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { - add("", "", codeDryRunTimeout, agentconfig.SeverityError, fmt.Sprintf("the dry run timed out after %s", TestTimeout)) + // Not attributable to authored Rego (vendor packages are evaluated too), so + // never a reason to reject. + add("", "", codeDryRunTimeout, agentconfig.SeverityWarning, fmt.Sprintf("the dry run timed out after %s; the policy contract was not fully checked", TestTimeout)) } return "" } diff --git a/internal/inlinepolicy/contract_test.go b/internal/inlinepolicy/contract_test.go index 2b25e7f..49ec405 100644 --- a/internal/inlinepolicy/contract_test.go +++ b/internal/inlinepolicy/contract_test.go @@ -143,6 +143,15 @@ func TestCheck_Contract_R63(t *testing.T) { } }) + t.Run("an authored test does not make vendor debt an error", func(t *testing.T) { + m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ + "untitled_test.rego": "package compliance_framework.untitled\n\ntest_ok if { count(violation) == 1 with input as {\"x\": true} }\n", + }}) + if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { + t.Fatalf("vendor debt must stay a warning, got %+v", errs) + } + }) + t.Run("authored package without a title is rejected", func(t *testing.T) { m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ "x.rego": "package compliance_framework.x\n\nviolation contains {\"id\": \"x\"} if input.x\n", diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 7812efe..9ca286b 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -297,20 +297,25 @@ const ( treeDir = "bundle" // currentLink is the per-bundle symlink to the active materialized directory. currentLink = "current" + // treeMarker marks a complete materialized directory in this layout. It sits next to + // treeDir, outside the tree. + treeMarker = ".ccf-tree" // Name prefixes of transient entries in a bundle directory. tmpPrefix = ".tmp-" currentTmpPrefix = ".current-" symlinkProbeEntry = ".symlink-probe-" ) -// writeOnce writes files under final/bundle unless that already exists: a temp dir (dirs -// 0755, files 0644) renamed into place. Losing a rename race reuses the winner's directory. +// writeOnce writes files under final/bundle unless final is already complete: a temp dir +// (dirs 0755, files 0644) with the completion marker, renamed into place. Losing a rename +// race reuses the winner's directory. func writeOnce(final string, files map[string][]byte) error { - if info, err := os.Stat(filepath.Join(final, treeDir)); err == nil && info.IsDir() { + if complete(final) { return nil } if _, err := os.Lstat(final); err == nil { - // A directory in an older layout (the tree directly under final): rebuild it. + // A directory in the layout before R67 (the tree directly under final, which may + // itself contain a bundle/ directory): rebuild it. if err := os.RemoveAll(final); err != nil { return err } @@ -335,9 +340,13 @@ func writeOnce(final string, files map[string][]byte) error { return err } } + if err := os.WriteFile(filepath.Join(tmp, treeMarker), nil, 0o644); err != nil { + cleanup() + return err + } if err := os.Rename(tmp, final); err != nil { cleanup() - if info, statErr := os.Stat(filepath.Join(final, treeDir)); statErr == nil && info.IsDir() { + if complete(final) { return nil } return err @@ -345,6 +354,12 @@ func writeOnce(final string, files map[string][]byte) error { return nil } +// complete reports whether final is a materialized directory in this layout. +func complete(final string) bool { + info, err := os.Stat(filepath.Join(final, treeMarker)) + return err == nil && info.Mode().IsRegular() +} + func randomSuffix() string { var rnd [6]byte _, _ = rand.Read(rnd[:]) From f88bde9ee37aa56d3a3c839bf773ea81cc30df5d Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 08:01:17 -0300 Subject: [PATCH 18/47] self-review: address round-2 pass 2 findings - contract: skip policy-manager's risk-template error only when this package's risk templates were already reported, not on any invalid-type. - docs: an authored non-test module makes a package authored. - Activate doc comment reflowed. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0003-remote-config-overlay.md | 4 ++-- docs/configuration.md | 4 ++-- internal/inlinepolicy/contract.go | 8 +++++--- internal/inlinepolicy/materialize.go | 5 +++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 68a2b83..0047135 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -68,8 +68,8 @@ The API's `regocheck` runs `policyeval.CheckContract` on the authored modules on agent, after the compile and the tests pass, adds what needs the whole tree: the static check on the vendor-only packages (warnings, never re-run on authored modules), and a dry run on an empty input through `policyeval.Execute` and `policy-manager`'s `GetRiskTemplates`, sandboxed like the tests. Decode errors and `Result.Issues` become located -`PolicyError`s with the contract codes: errors for packages that contain an authored module, warnings for vendor-only -packages; conflicts that only show on `{}` and input-dependent titles are warnings. A package that fails to evaluate is +`PolicyError`s with the contract codes: errors for packages that contain an authored non-test module, warnings for +vendor-only packages; conflicts that only show on `{}` and input-dependent titles are warnings. A package that fails to evaluate is left out of the next attempt, so one broken package does not hide the others. A compile error in a vendor file whose package an authored module also defines carries an override hint (R65). A package defined in two of a plugin's policy paths is a warning naming both (R66). The per-plugin results are de-duplicated before they are reported. diff --git a/docs/configuration.md b/docs/configuration.md index 0a06af9..247102f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -270,8 +270,8 @@ policy_bundles: map of strings, and valid `risk_templates`. The API checks the authored modules statically when an overlay is saved. The agent, which sees the whole tree, also checks the vendor packages statically and then dry-runs every package on an empty input (`{}` plus the plugin's `policy_data`), sandboxed, through the same calls a plugin makes. A - problem in a package that has an authored module (including an override) is an **error**; in a package only the - vendor defines it is a **warning**, as is an evaluation conflict that only shows on `{}` or a `title` that depends on + problem in a package that has an authored non-test module (including an override) is an **error**; in a package + only the vendor defines (an authored test alone does not count) it is a **warning**, as is an evaluation conflict that only shows on `{}` or a `title` that depends on the input. So an override that leaves a package without a `title` is rejected: that package would record no evidence. - **Vendor tests that no longer compile (R65)** reject the revision: plugins compile `_test.rego` files too, so such a diff --git a/internal/inlinepolicy/contract.go b/internal/inlinepolicy/contract.go index b367194..d475695 100644 --- a/internal/inlinepolicy/contract.go +++ b/internal/inlinepolicy/contract.go @@ -172,14 +172,16 @@ func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePacka var out []agentconfig.PolicyError seen := map[string]bool{} // package + code + message - reportedCodes := map[[2]string]bool{} + riskReported := map[string]bool{} add := func(pkg, file, code, severity, msg string) { key := pkg + "\x00" + code + "\x00" + msg if seen[key] { return } seen[key] = true - reportedCodes[[2]string{pkg, code}] = true + if code == policyeval.IssueInvalidRiskTemplate || strings.Contains(msg, "risk_templates") { + riskReported[pkg] = true + } out = append(out, agentconfig.PolicyError{ Bundle: in.Bundle, Path: file, @@ -300,7 +302,7 @@ func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePacka break } var rte *policyManager.RiskTemplateError - if errors.As(err, &rte) && (reportedCodes[[2]string{rte.Package, policyeval.IssueInvalidRiskTemplate}] || reportedCodes[[2]string{rte.Package, policyeval.IssueInvalidType}]) { + if errors.As(err, &rte) && riskReported[rte.Package] { // ValidateResult already reported this package's risk templates. excluded[rte.Package] = true } else { diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 9ca286b..03cbe45 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -392,8 +392,9 @@ func symlinksSupported(root string) bool { // tree or the new one, never neither (macOS APFS may fail such a racing lookup with EINVAL). // It is not a snapshot for a reader walking the tree while it is swapped either. Callers // therefore swap only while no plugin of the previous configuration runs (the agent does it -// between two configuration runs, after the reload drain), and serialize Activate with GC. It is a no-op when the tree is already active or the file system has no -// symlinks (plugins then receive dir itself). +// between two configuration runs, after the reload drain), and serialize Activate with GC. +// It is a no-op when the tree is already active or the file system has no symlinks (plugins +// then receive dir itself). func Activate(root, name, dir string) error { bundleDir := filepath.Join(root, name) versionDir := filepath.Dir(filepath.Clean(dir)) From ac8a1d3195c307cb8360c7ca1626d456844e2f3d Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:43:54 -0300 Subject: [PATCH 19/47] chore: pin compliance-framework/api to PR #465 head f511c44 (round 3) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index b5e7137..6447ad9 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.19.1-0.20261001101400-6c801a34ca14 + github.com/compliance-framework/api v0.19.1-0.20261001141932-f511c440e087 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 diff --git a/go.sum b/go.sum index 7eeed11..0ee3579 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.19.1-0.20261001101400-6c801a34ca14 h1:Ni4ZWSmfCwKx4I3onC02JrVnRB3twoURmKL9r3wtrVw= -github.com/compliance-framework/api v0.19.1-0.20261001101400-6c801a34ca14/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.19.1-0.20261001141932-f511c440e087 h1:M8INHyuGDLYQG4LQQ8mmTaQcLsyoVabWHeuhF9vPOd0= +github.com/compliance-framework/api v0.19.1-0.20261001141932-f511c440e087/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= From e6ec2afb19153d86cf39f78bb6cde6a24d2c8c90 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:43:54 -0300 Subject: [PATCH 20/47] feat(policy-manager): seed evidence with an optional policy_id (R74) A module that declares policy_id writes to a stream that follows the policy, not where its bundle lives: policyeval.SeedPath replaces the policy_file seed and, when the plugin labels carry one, the _policy_path seed. A policy_id equal to the legacy policy file reproduces the legacy UUID, so an override can continue the vendor stream. Without a policy_id the seed is unchanged byte for byte (golden test). Evidence keeps the real _policy_path label and gains _policy_id. --- policy-manager/policy-manager.go | 67 +++++++-- policy-manager/policy-manager_test.go | 2 +- policy-manager/policy_id_test.go | 199 ++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 16 deletions(-) create mode 100644 policy-manager/policy_id_test.go diff --git a/policy-manager/policy-manager.go b/policy-manager/policy-manager.go index 3507d50..f07c0cf 100644 --- a/policy-manager/policy-manager.go +++ b/policy-manager/policy-manager.go @@ -148,7 +148,7 @@ func (p *PolicyProcessor) GenerateResults(ctx context.Context, policyPath string // Observation UUID should differ for each individual subject, but remain consistent when validating the same policy for the same subject. // This acts as an identifier to show the history of an observation. - evidence, err := p.newEvidence(result, activities) + evidence, err := p.newEvidence(result, policyPath, activities) if err != nil { resultErr = errors.Join(resultErr, err) continue @@ -221,16 +221,29 @@ func validateNewEvidence(result Result) error { return nil } -func (p *PolicyProcessor) newEvidence(result Result, activities []*proto.Activity) (*proto.Evidence, error) { +// Evidence labels that name the policy an evidence came from. +const ( + labelPolicy = "_policy" + labelPolicyPath = "_policy_path" + // labelPolicyID is the policy's policy_id, when it declares one (R74). + labelPolicyID = "_policy_id" +) + +// newEvidence builds the evidence for one policy result. policyPath is the path the agent +// passed the plugin for the result's bundle. +// +// The evidence UUID is seeded with the policy's package and file and the plugin's labels, so +// the same policy and subject always produce the same UUID: that is the evidence stream. +// When the policy declares a policy_id, policyeval.SeedPath replaces the file and, if the +// plugin labels carry one, the _policy_path seed value, so the stream follows the policy +// rather than where its bundle lives. Without a policy_id the seed is exactly what it has +// always been, so existing streams keep their UUIDs. +func (p *PolicyProcessor) newEvidence(result Result, policyPath string, activities []*proto.Activity) (*proto.Evidence, error) { if err := validateNewEvidence(result); err != nil { return nil, err } - evidenceUUID, err := sdk.SeededUUID(MergeMaps(map[string]string{ - "type": "evidence", - "policy": result.Policy.Package.PurePackage(), - "policy_file": result.Policy.File, - }, p.labels)) + evidenceUUID, err := sdk.SeededUUID(p.evidenceSeed(result, policyPath)) if err != nil { return nil, err } @@ -239,15 +252,20 @@ func (p *PolicyProcessor) newEvidence(result Result, activities []*proto.Activit if result.Labels != nil { resultLabels = *result.Labels } + labels := MergeMaps( + map[string]string{ + labelPolicy: result.Policy.Package.PurePackage(), + }, + p.labels, + resultLabels, + ) + if result.Policy.ID != "" { + // Set last: it records the identity the UUID was seeded with. + labels[labelPolicyID] = result.Policy.ID + } evidence := proto.Evidence{ - UUID: evidenceUUID.String(), - Labels: MergeMaps( - map[string]string{ - "_policy": result.Policy.Package.PurePackage(), - }, - p.labels, - resultLabels, - ), + UUID: evidenceUUID.String(), + Labels: labels, Start: timestamppb.New(time.Now()), End: timestamppb.New(time.Now()), Origins: []*proto.Origin{{Actors: p.actors}}, @@ -260,6 +278,25 @@ func (p *PolicyProcessor) newEvidence(result Result, activities []*proto.Activit return &evidence, nil } +// evidenceSeed returns the values an evidence UUID is seeded with (see newEvidence). Only +// the seed changes for a policy_id: the evidence keeps the plugin's real _policy_path label. +func (p *PolicyProcessor) evidenceSeed(result Result, policyPath string) map[string]string { + policyFile := result.Policy.File + labels := p.labels + if result.Policy.ID != "" { + seedFile, seedPolicyPath := policyeval.SeedPath(result.Policy.ID, result.Policy.File, policyPath) + policyFile = seedFile + if _, ok := p.labels[labelPolicyPath]; ok { + labels = MergeMaps(p.labels, map[string]string{labelPolicyPath: seedPolicyPath}) + } + } + return MergeMaps(map[string]string{ + "type": "evidence", + "policy": result.Policy.Package.PurePackage(), + "policy_file": policyFile, + }, labels) +} + func (pm *PolicyManager) GetRiskTemplates(ctx context.Context) (map[string][]*proto.RiskTemplate, error) { query, err := pm.prepareForEval(ctx, rego.Query("data.compliance_framework"), diff --git a/policy-manager/policy-manager_test.go b/policy-manager/policy-manager_test.go index b9152f4..ce8ad89 100644 --- a/policy-manager/policy-manager_test.go +++ b/policy-manager/policy-manager_test.go @@ -360,7 +360,7 @@ func TestPolicyProcessorNewEvidenceRejectsMissingTitle(t *testing.T) { Package: Package("data.compliance_framework.missing_title"), }, EvalOutput: &EvalOutput{}, - }, nil) + }, "", nil) assert.Nil(t, evidence) assert.EqualError(t, err, "evidence title is required") diff --git a/policy-manager/policy_id_test.go b/policy-manager/policy_id_test.go new file mode 100644 index 0000000..c25f89d --- /dev/null +++ b/policy-manager/policy_id_test.go @@ -0,0 +1,199 @@ +package policy_manager + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/compliance-framework/agent/runner/proto" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Policy identity (R74): a policy_id replaces the location in the evidence UUID seed, and +// without one the seed is exactly what plugins have always used. + +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" + // inlinePath is the stable path an inline bundle is passed as (R67): absolute. + inlinePath = "/app/.compliance-framework/state/local-dev/inline/test/current/bundle" +) + +func evidenceUUID(t *testing.T, labels map[string]string, policyPath, file, pkg, id string) *proto.Evidence { + t.Helper() + p := &PolicyProcessor{labels: labels} + e, err := p.newEvidence(Result{ + Policy: Policy{File: file, Package: Package(pkg), ID: id}, + EvalOutput: &EvalOutput{Title: Pointer("t")}, + }, policyPath, nil) + require.NoError(t, err) + return e +} + +func sshLabels(policyPath string) map[string]string { + return map[string]string{"type": "ssh", "hostname": "web-1", "_policy_path": policyPath} +} + +// TestEvidenceSeedWithoutPolicyIDIsUnchanged pins the UUIDs plugins produced before R74 for +// several label and path combinations: without a policy_id they must never change. +func TestEvidenceSeedWithoutPolicyIDIsUnchanged(t *testing.T) { + cases := []struct { + name string + labels map[string]string + policyPath string + file, pkg string + want string + }{ + {"relative OCI path", sshLabels(vendorPath), vendorPath, vendorPath + "/ssh_deny_password_auth.rego", "data.compliance_framework.ssh_deny_password_auth", "cede5222-a458-4465-8134-c3751575cdd9"}, + {"absolute inline path, nested file", sshLabels(inlinePath), inlinePath, inlinePath + "/ssh/banner.rego", "data.compliance_framework.banner", "966b3869-b39b-4b2b-9257-7f475e3bb54c"}, + {"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", "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/", "policies/a.rego", "data.compliance_framework.a", "98fc06e7-b1a4-4d83-a272-df4fdd75d06c"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := evidenceUUID(t, tc.labels, tc.policyPath, tc.file, tc.pkg, "") + assert.Equal(t, tc.want, e.UUID) + assert.NotContains(t, e.Labels, labelPolicyID) + }) + } +} + +// TestPolicyIDContinuesTheLegacyStream: an override that declares the replaced policy's +// legacy policy_file as its policy_id writes to the replaced policy's stream, wherever the +// override lives. +func TestPolicyIDContinuesTheLegacyStream(t *testing.T) { + const pkg = "data.compliance_framework.ssh_deny_password_auth" + cases := []struct { + name string + legacyPath, file string + overridePath string + overridePathLabels string + }{ + {"vendor OCI bundle overridden inline", vendorPath, "ssh_deny_password_auth.rego", inlinePath, inlinePath}, + {"nested file", vendorPath, "ssh/ssh_deny_password_auth.rego", inlinePath, inlinePath}, + {"inline bundle renamed", inlinePath, "ssh_deny_password_auth.rego", "/app/.compliance-framework/state/local-dev/inline/renamed/current/bundle", "/app/.compliance-framework/state/local-dev/inline/renamed/current/bundle"}, + {"OCI tag bumped", vendorPath, "ssh_deny_password_auth.rego", ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.3.0/policies", ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.3.0/policies"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + legacyFile := tc.legacyPath + "/" + tc.file + legacy := evidenceUUID(t, sshLabels(tc.legacyPath), tc.legacyPath, legacyFile, pkg, "") + override := evidenceUUID(t, sshLabels(tc.overridePathLabels), tc.overridePath, tc.overridePath+"/"+tc.file, pkg, legacyFile) + assert.Equal(t, legacy.UUID, override.UUID, "the override continues the legacy stream") + + assert.Equal(t, tc.overridePathLabels, override.Labels["_policy_path"], "the evidence keeps the real _policy_path") + assert.Equal(t, legacyFile, override.Labels[labelPolicyID]) + }) + } +} + +// TestOpaquePolicyIDIsLocationIndependent: an opaque policy_id gives the same stream +// whatever the bundle is called or where it lives, and a different stream from the +// path-based one. +func TestOpaquePolicyIDIsLocationIndependent(t *testing.T) { + const pkg = "data.compliance_framework.ssh_deny_password_auth" + const id = "ssh-deny-password-auth" + paths := []string{ + inlinePath, + "/app/.compliance-framework/state/local-dev/inline/renamed/current/bundle", + "/var/lib/ccf/state/inline/test/current/bundle", + vendorPath, + } + var uuids []string + for _, path := range paths { + e := evidenceUUID(t, sshLabels(path), path, path+"/ssh_deny_password_auth.rego", pkg, id) + uuids = append(uuids, e.UUID) + assert.Equal(t, id, e.Labels[labelPolicyID]) + assert.Equal(t, path, e.Labels["_policy_path"]) + } + for _, u := range uuids[1:] { + assert.Equal(t, uuids[0], u) + } + pathBased := evidenceUUID(t, sshLabels(inlinePath), inlinePath, inlinePath+"/ssh_deny_password_auth.rego", pkg, "") + assert.NotEqual(t, pathBased.UUID, uuids[0]) + + // The package stays in the seed: a different package is a different stream. + other := evidenceUUID(t, sshLabels(inlinePath), inlinePath, inlinePath+"/ssh_deny_password_auth.rego", "data.compliance_framework.other", id) + assert.NotEqual(t, uuids[0], other.UUID) +} + +// TestPolicyIDWithoutPolicyPathLabel: plugins that do not label _policy_path seed only the +// policy_file with the policy_id; no _policy_path key is added to their seed. +func TestPolicyIDWithoutPolicyPathLabel(t *testing.T) { + labels := map[string]string{"_plugin": "test-plugin"} + p := &PolicyProcessor{labels: labels} + seed := p.evidenceSeed(Result{Policy: Policy{File: "/b/x.rego", Package: "data.compliance_framework.x", ID: "x"}}, "/b") + assert.Equal(t, map[string]string{"type": "evidence", "policy": "compliance_framework.x", "policy_file": "x", "_plugin": "test-plugin"}, seed) + assert.Equal(t, map[string]string{"_plugin": "test-plugin"}, labels, "the plugin's labels are not modified") + + labels = sshLabels("/b") + p = &PolicyProcessor{labels: labels} + seed = p.evidenceSeed(Result{Policy: Policy{File: "/b/x.rego", Package: "data.compliance_framework.x", ID: "x"}}, "/b") + assert.Equal(t, "x", seed["_policy_path"]) + assert.Equal(t, "/b", labels["_policy_path"], "the plugin's labels are not modified") +} + +// TestGenerateResultsSeedsWithThePolicyID runs real bundles the way a plugin does: a vendor +// bundle without policy_id, and an override in another directory whose policy_id is the +// vendor module's legacy path, produce the same evidence UUID. Relative paths stay relative. +func TestGenerateResultsSeedsWithThePolicyID(t *testing.T) { + for _, relative := range []bool{false, true} { + name := "absolute" + if relative { + name = "relative" + } + t.Run(name, func(t *testing.T) { + root := t.TempDir() + if relative { + t.Chdir(root) + root = "." + } + vendor := filepath.Join(root, "vendor", "v0.2.0", "policies") + override := filepath.Join(root, "inline", "test", "current", "bundle") + legacyFile := vendor + "/ssh.rego" + writeModule(t, vendor, "ssh.rego", `package compliance_framework.ssh + +import rego.v1 + +title := "ssh" + +violation contains {"id": "v"} if input.bad +`) + writeModule(t, override, "ssh.rego", `package compliance_framework.ssh + +import rego.v1 + +policy_id := "`+legacyFile+`" + +title := "ssh (override)" + +violation contains {"id": "v"} if input.bad +`) + + generate := func(policyPath string) *proto.Evidence { + t.Helper() + processor := NewPolicyProcessor(hclog.NewNullLogger(), sshLabels(policyPath), nil, nil, nil, nil, nil, nil) + evidence, err := processor.GenerateResults(context.Background(), policyPath, map[string]any{"bad": true}) + require.NoError(t, err) + require.Len(t, evidence, 1) + return evidence[0] + } + legacy, overridden := generate(vendor), generate(override) + assert.Equal(t, legacy.UUID, overridden.UUID, "the override continues the vendor stream") + assert.NotContains(t, legacy.Labels, labelPolicyID) + assert.Equal(t, legacyFile, overridden.Labels[labelPolicyID]) + assert.Equal(t, override, overridden.Labels["_policy_path"]) + }) + } +} + +func writeModule(t *testing.T, dir, name, src string) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644)) +} From ba477984fcd4b896ffd6b42289f060716c66596f Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:07 -0300 Subject: [PATCH 21/47] chore: pin compliance-framework/api to PR #465 head 5449a31 SeedPath derives the bundle-relative path against the cleaned policy path and keeps a policy_id equal to the policy's own file legacy; agentconfig gains the R79 plugins[].inline-policies values and plugin-lib-inline-unsupported. golang.org/x/mod becomes a direct dependency (semver for plugin library versions). --- go.mod | 3 ++- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 6447ad9..d31688f 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.19.1-0.20261001141932-f511c440e087 + github.com/compliance-framework/api v0.19.1-0.20261001145443-5449a31fd701 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 @@ -19,6 +19,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + golang.org/x/mod v0.36.0 golang.org/x/sync v0.21.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 diff --git a/go.sum b/go.sum index 0ee3579..c4db27d 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.19.1-0.20261001141932-f511c440e087 h1:M8INHyuGDLYQG4LQQ8mmTaQcLsyoVabWHeuhF9vPOd0= -github.com/compliance-framework/api v0.19.1-0.20261001141932-f511c440e087/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.19.1-0.20261001145443-5449a31fd701 h1:mzUmk/jkuz1zGlCQSfsY94HhRJjPTIbMbo82CinZn9A= +github.com/compliance-framework/api v0.19.1-0.20261001145443-5449a31fd701/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= From 2910ca68ff760ab90de34ccd0bf9f964da4822e5 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:07 -0300 Subject: [PATCH 22/47] test(policy-manager): a ./-relative local source continues its stream (R74) OPA gives plugins the cleaned policy file, so a policy_id of path.Join(, ) reproduces the policy_file seed of a local source configured as ./policies. --- policy-manager/policy-manager.go | 4 ++-- policy-manager/policy_id_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/policy-manager/policy-manager.go b/policy-manager/policy-manager.go index f07c0cf..4551c2f 100644 --- a/policy-manager/policy-manager.go +++ b/policy-manager/policy-manager.go @@ -264,8 +264,8 @@ func (p *PolicyProcessor) newEvidence(result Result, policyPath string, activiti labels[labelPolicyID] = result.Policy.ID } evidence := proto.Evidence{ - UUID: evidenceUUID.String(), - Labels: labels, + UUID: evidenceUUID.String(), + Labels: labels, Start: timestamppb.New(time.Now()), End: timestamppb.New(time.Now()), Origins: []*proto.Origin{{Actors: p.actors}}, diff --git a/policy-manager/policy_id_test.go b/policy-manager/policy_id_test.go index c25f89d..93aab33 100644 --- a/policy-manager/policy_id_test.go +++ b/policy-manager/policy_id_test.go @@ -3,6 +3,7 @@ package policy_manager import ( "context" "os" + "path" "path/filepath" "testing" @@ -197,3 +198,29 @@ func writeModule(t *testing.T, dir, name, src string) { require.NoError(t, os.MkdirAll(dir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644)) } + +// TestPolicyIDContinuesADotSlashLocalSource: a local source configured as "./policies" is +// passed to plugins literally, while OPA gives them the cleaned file +// ("policies/"). A policy_id of path.Join(, ) reproduces that +// policy_file seed, so plugins that seed only with policy_file continue the stream. +// +// Plugins that also label _policy_path seed with the literal "./policies", which +// policyeval.SeedPath cannot recover from a cleaned policy_id; see the round-3 notes. +func TestPolicyIDContinuesADotSlashLocalSource(t *testing.T) { + t.Chdir(t.TempDir()) + const vendor = "./policies" + override := filepath.Join(t.TempDir(), "inline", "b", "current", "bundle") + writeModule(t, vendor, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\ntitle := \"a\"\n") + writeModule(t, override, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\npolicy_id := \""+path.Join(vendor, "a.rego")+"\"\n\ntitle := \"a\"\n") + + labels := map[string]string{"type": "local", "hostname": "web-1"} + generate := func(policyPath string) *proto.Evidence { + t.Helper() + processor := NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil) + evidence, err := processor.GenerateResults(context.Background(), policyPath, map[string]any{}) + require.NoError(t, err) + require.Len(t, evidence, 1) + return evidence[0] + } + assert.Equal(t, generate(vendor).UUID, generate(override).UUID) +} From 779788c315f9ba81fab7a33890ad332a50eb402b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:10 -0300 Subject: [PATCH 23/47] feat(inlinepolicy): module identities and override streams (R75, R76) Materialize records the evidence identity (package, static policy_id) of every policy module of the bundle and of its extends tree, and the authored modules that define violation as a set or declare policy_id. OverrideStreams warns when an override does not continue the stream of the vendor module it replaces: a changed package (policy-package-changed) or seeds that differ from the vendor's (policy-stream-forked), comparing what plugins would seed from the extends source's path and the bundle's path. --- internal/inlinepolicy/identity.go | 254 +++++++++++++++++++++++++ internal/inlinepolicy/identity_test.go | 85 +++++++++ internal/inlinepolicy/materialize.go | 11 ++ 3 files changed, 350 insertions(+) create mode 100644 internal/inlinepolicy/identity.go create mode 100644 internal/inlinepolicy/identity_test.go diff --git a/internal/inlinepolicy/identity.go b/internal/inlinepolicy/identity.go new file mode 100644 index 0000000..732acd9 --- /dev/null +++ b/internal/inlinepolicy/identity.go @@ -0,0 +1,254 @@ +package inlinepolicy + +import ( + "fmt" + "path" + "path/filepath" + "strings" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/pkg/policyeval" + "github.com/open-policy-agent/opa/v1/ast" +) + +// Evidence identity (R74, R75). A plugin seeds each evidence UUID with the policy's package, +// its file (the path the agent passed the plugin joined with the module's path in the +// bundle) and, through its labels, that path; a policy_id replaces the location +// (policyeval.SeedPath). The agent reads policy_id statically, as the API's contract check +// does: only `policy_id := ""` declared once per package counts, which is the only +// form CheckContract accepts. + +// ModuleIdentity is what decides the evidence stream of one policy module. +type ModuleIdentity struct { + Path string // slash-separated, relative to the policy root + Package string // without "data." + PolicyID string // the package's policy_id, "" when it declares none (or not validly) +} + +// Site is where an authored construct is. +type Site struct { + Path string + Row, Col int +} + +// Identities returns the identity of every non-test module of a compliance_framework +// package in files, sorted by path. Modules that do not parse are skipped: the checks report +// them. +func Identities(files map[string][]byte) []ModuleIdentity { + modules := parseModules(files) + ids := policyIDs(modules) + var out []ModuleIdentity + for _, p := range sortedKeys(modules) { + pkg := packageOf(modules[p]) + if policyeval.IsTestFile(p) || !policyeval.IsPolicyPackage(pkg) { + continue + } + out = append(out, ModuleIdentity{Path: p, Package: pkg, PolicyID: ids[pkg]}) + } + return out +} + +// TreeIdentities reads the policy tree at dir and returns Identities of it. +func TreeIdentities(dir string) ([]ModuleIdentity, error) { + files, _, err := readTree(dir) + if err != nil { + return nil, err + } + return Identities(files), nil +} + +// authoredSites returns the authored non-test modules of files that define violation as a +// set (`violation contains ...`, R76) and that declare a policy_id rule. +func authoredSites(files map[string][]byte, authored map[string]bool) (setViolations, policyIDRules []Site) { + for _, p := range sortedKeys(files) { + if !authored[p] || !strings.HasSuffix(p, ".rego") || policyeval.IsTestFile(p) { + continue + } + mod, err := ast.ParseModuleWithOpts(p, string(files[p]), ast.ParserOptions{RegoVersion: ast.RegoV1}) + if err != nil || mod == nil || !policyeval.IsPolicyPackage(packageOf(mod)) { + continue + } + var setSite, idSite *Site + for _, rule := range mod.Rules { + loc := rule.Head.Location + if loc == nil { + loc = rule.Location + } + site := Site{Path: p} + if loc != nil { + site.Row, site.Col = loc.Row, loc.Col + } + switch ruleName(rule) { + case "violation": + if setSite == nil && rule.Head.Key != nil && rule.Head.Value == nil { + setSite = &site + } + case "policy_id": + if idSite == nil { + idSite = &site + } + } + } + if setSite != nil { + setViolations = append(setViolations, *setSite) + } + if idSite != nil { + policyIDRules = append(policyIDRules, *idSite) + } + } + return setViolations, policyIDRules +} + +// SeedOf returns the evidence seed values (policy_file, _policy_path) of module id when a +// plugin loads it from pluginPath, as policy-manager computes them: OPA gives plugins the +// policy file as the path joined with the module's path (cleaned), and policyeval.SeedPath +// applies the policy_id. +func SeedOf(id ModuleIdentity, pluginPath string) (string, string) { + file := filepath.Join(pluginPath, filepath.FromSlash(id.Path)) + return policyeval.SeedPath(id.PolicyID, file, pluginPath) +} + +// ContinuityPolicyID is the policy_id that makes a module at rel in a bundle continue the +// stream of the module at rel in the policy path pluginPath when that one has no policy_id: +// its legacy policy file, which plugins see cleaned (OPA joins the path and the file). +func ContinuityPolicyID(pluginPath, rel string) string { + return path.Join(pluginPath, rel) +} + +// OverrideStreams warns about authored modules of an extends bundle that replace a vendor +// module at the same path but do not continue its evidence stream (R75): a changed package +// (policy-package-changed), or a policy_id that differs from the vendor's, or, when the +// vendor has none, from ContinuityPolicyID of the extends source (policy-stream-forked). +// Plugins that load the bundle in place of the extends source then start a new stream for +// that policy. +func OverrideStreams(m *Materialized) []agentconfig.PolicyError { + if m == nil || m.Extends == nil { + return nil + } + vendor := map[string]ModuleIdentity{} + for _, id := range m.ExtendsIdentities { + vendor[id.Path] = id + } + var out []agentconfig.PolicyError + for _, id := range m.Identities { + v, replaced := vendor[id.Path] + if !replaced || !m.Authored[id.Path] { + continue + } + warn := func(code, format string, args ...any) { + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: id.Path, Severity: agentconfig.SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)}) + } + if id.Package != v.Package { + warn(agentconfig.PolicyCodePolicyPackageChanged, + "the override of %s changes its package from %s to %s, which starts a new evidence stream for it; keep `package %s` to continue the vendor policy's stream", + id.Path, v.Package, id.Package, v.Package) + continue + } + // Compare what plugins seed with: the vendor module loaded from the extends source, + // and the override loaded from the bundle's path. + vendorFile, vendorPath := SeedOf(v, m.ExtendsDir) + file, policyPath := SeedOf(id, m.Path) + if file == vendorFile && policyPath == vendorPath { + continue + } + want := v.PolicyID + if want == "" { + want = ContinuityPolicyID(m.ExtendsDir, id.Path) + } + got := "no policy_id" + if id.PolicyID != "" { + got = fmt.Sprintf("policy_id %q", id.PolicyID) + } + warn(CodePolicyStreamForked, + "the override of %s has %s, so plugins that load this bundle instead of %s record its evidence in a new stream; declare `policy_id := %q` to continue the vendor policy's stream", + id.Path, got, m.Extends.Source, want) + } + return out +} + +// CodePolicyStreamForked is the PolicyError code of an override whose policy_id does not +// continue the evidence stream of the vendor module it replaces (R75). Warning. +const CodePolicyStreamForked = "policy-stream-forked" + +func parseModules(files map[string][]byte) map[string]*ast.Module { + out := map[string]*ast.Module{} + for p, src := range files { + if !strings.HasSuffix(p, ".rego") { + continue + } + mod, err := ast.ParseModuleWithOpts(p, string(src), ast.ParserOptions{RegoVersion: ast.RegoV1}) + if err == nil && mod != nil && mod.Package != nil { + out[p] = mod + } + } + return out +} + +// policyIDs returns each package's policy_id: set when the package declares exactly one +// policy_id rule, as an unconditional literal that policyeval.ValidPolicyID accepts. +func policyIDs(modules map[string]*ast.Module) map[string]string { + rules := map[string][]*ast.Rule{} + for p, mod := range modules { + if policyeval.IsTestFile(p) { + continue + } + for _, rule := range mod.Rules { + if ruleName(rule) == "policy_id" { + pkg := packageOf(mod) + rules[pkg] = append(rules[pkg], rule) + } + } + } + out := map[string]string{} + for pkg, rs := range rules { + if len(rs) != 1 { + continue + } + if id, ok := literalPolicyID(rs[0]); ok { + out[pkg] = id + } + } + return out +} + +func literalPolicyID(rule *ast.Rule) (string, bool) { + if len(rule.Head.Ref()) != 1 || len(rule.Head.Args) > 0 || rule.Head.Key != nil || rule.Head.Value == nil || + rule.Default || rule.Else != nil || !unconditional(rule) { + return "", false + } + s, ok := rule.Head.Value.Value.(ast.String) + if !ok || !policyeval.ValidPolicyID(string(s)) { + return "", false + } + return string(s), true +} + +func unconditional(rule *ast.Rule) bool { + if len(rule.Body) != 1 { + return false + } + expr := rule.Body[0] + if expr.Negated || len(expr.With) > 0 { + return false + } + term, ok := expr.Terms.(*ast.Term) + return ok && term.Value.Compare(ast.Boolean(true)) == 0 +} + +func ruleName(rule *ast.Rule) string { + ref := rule.Head.Ref() + if len(ref) == 0 { + return "" + } + if v, ok := ref[0].Value.(ast.Var); ok { + return string(v) + } + return "" +} + +func packageOf(mod *ast.Module) string { + if mod == nil || mod.Package == nil { + return "" + } + return strings.TrimPrefix(mod.Package.Path.String(), "data.") +} diff --git a/internal/inlinepolicy/identity_test.go b/internal/inlinepolicy/identity_test.go new file mode 100644 index 0000000..a40c31a --- /dev/null +++ b/internal/inlinepolicy/identity_test.go @@ -0,0 +1,85 @@ +package inlinepolicy + +import ( + "context" + "testing" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIdentities(t *testing.T) { + ids := Identities(map[string][]byte{ + "a.rego": []byte("package compliance_framework.a\n\npolicy_id := \"a-id\"\n\ntitle := \"a\"\n"), + "a_extra.rego": []byte("package compliance_framework.a\n\ntitle2 := \"x\"\n"), + "a_test.rego": []byte("package compliance_framework.a\n\npolicy_id := \"ignored\"\n"), + "b/b.rego": []byte("package compliance_framework.b\n\ntitle := \"b\"\n"), + "twice.rego": []byte("package compliance_framework.twice\n\npolicy_id := \"one\"\n"), + "twice2.rego": []byte("package compliance_framework.twice\n\npolicy_id := \"two\"\n"), + "cond.rego": []byte("package compliance_framework.cond\n\npolicy_id := \"c\" if input.x\n"), + "computed.rego": []byte("package compliance_framework.computed\n\npolicy_id := concat(\"-\", [\"a\", \"b\"])\n"), + "lib.rego": []byte("package ccf_libs.helpers\n\npolicy_id := \"lib\"\n"), + "broken.rego": []byte("package compliance_framework.broken\n\ntitle := \n"), + "data.json": []byte("{}"), + }) + assert.Equal(t, []ModuleIdentity{ + {Path: "a.rego", Package: "compliance_framework.a", PolicyID: "a-id"}, + {Path: "a_extra.rego", Package: "compliance_framework.a", PolicyID: "a-id"}, // the package's policy_id + {Path: "b/b.rego", Package: "compliance_framework.b"}, + {Path: "computed.rego", Package: "compliance_framework.computed"}, // only literals count + {Path: "cond.rego", Package: "compliance_framework.cond"}, + {Path: "twice.rego", Package: "compliance_framework.twice"}, // declared twice: none + {Path: "twice2.rego", Package: "compliance_framework.twice"}, + }, ids) +} + +func TestAuthoredSites(t *testing.T) { + files := map[string][]byte{ + "set.rego": []byte("package compliance_framework.set\n\nimport rego.v1\n\npolicy_id := \"s\"\n\nviolation contains {\"id\": \"x\"} if input.bad\n"), + "object.rego": []byte("package compliance_framework.object\n\nimport rego.v1\n\nviolation[{\"id\": \"x\"}] if input.bad\n"), + "vendor.rego": []byte("package compliance_framework.vendor\n\nimport rego.v1\n\nviolation contains {\"id\": \"x\"} if input.bad\n"), + "set_test.rego": []byte("package compliance_framework.set\n\nimport rego.v1\n\nviolation contains 1 if true\n"), + } + set, ids := authoredSites(files, map[string]bool{"set.rego": true, "object.rego": true, "set_test.rego": true}) + assert.Equal(t, []Site{{Path: "set.rego", Row: 7, Col: 1}}, set, "only authored set-form violations; the object form works with every plugin") + assert.Equal(t, []Site{{Path: "set.rego", Row: 5, Col: 1}}, ids) +} + +// TestOverrideStreams_R75: an override continues the vendor module's stream only with the +// vendor's package and policy_id, or, when the vendor has none, the vendor's legacy policy +// file as its policy_id. +func TestOverrideStreams_R75(t *testing.T) { + const vendorID = "package compliance_framework.ided\n\nimport rego.v1\n\npolicy_id := \"vendor-id\"\n\ntitle := \"x\"\n" + dir, resolve := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth, "ided.rego": vendorID}) + continuing := "package compliance_framework.banner\n\nimport rego.v1\n\npolicy_id := \"" + ContinuityPolicyID(dir, "banner.rego") + "\"\n\ntitle := \"Banner\"\n" + cases := []struct { + name string + modules map[string]string + want map[string]string // path -> code + }{ + {"continues the legacy stream", map[string]string{"banner.rego": continuing}, map[string]string{}}, + {"keeps the vendor policy_id", map[string]string{"ided.rego": vendorID + "\n# changed\n"}, map[string]string{}}, + {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{"banner.rego": CodePolicyStreamForked}}, + {"changed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\npolicy_id := \"other\"\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, + {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, + {"changed package", map[string]string{"max_auth.rego": "package compliance_framework.max_auth_v2\n\ntitle := \"x\"\n"}, map[string]string{"max_auth.rego": agentconfig.PolicyCodePolicyPackageChanged}}, + {"new module", map[string]string{"new.rego": "package compliance_framework.new\n\ntitle := \"x\"\n"}, map[string]string{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: tc.modules}, resolve) + require.NoError(t, err) + got := map[string]string{} + for _, e := range OverrideStreams(m) { + require.Equal(t, agentconfig.SeverityWarning, e.Severity) + require.Equal(t, "b", e.Bundle) + got[e.Path] = e.Code + } + assert.Equal(t, tc.want, got) + }) + } + m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Modules: map[string]string{"banner.rego": vendorBanner}}, resolve) + require.NoError(t, err) + assert.Empty(t, OverrideStreams(m), "a bundle without extends overrides nothing") +} diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 03cbe45..cfa5597 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -82,6 +82,14 @@ type Materialized struct { Authored map[string]bool AuthoredTests []string Warnings []agentconfig.PolicyError // delete of a missing path, skipped symlink, stray data file + + // Identities are the evidence identities of the tree's policy modules, and + // ExtendsIdentities those of the extends tree (R75). + Identities, ExtendsIdentities []ModuleIdentity + // SetViolations are the authored modules that define violation as a set, which plugins + // built on an agent library older than v0.7.1 cannot evaluate; PolicyIDRules are the + // authored modules that declare policy_id, which plugins built before R74 ignore (R76). + SetViolations, PolicyIDRules []Site } // Materialize builds bundle name in the R17 order (extends tree, delete, modules, data), @@ -129,6 +137,7 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu Files: inventory(baseFiles), } m.ExtendsDir = dir + m.ExtendsIdentities = Identities(baseFiles) files = baseFiles } @@ -210,6 +219,8 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu // 7. Inventory. m.Files = inventory(files) slices.Sort(m.AuthoredTests) + m.Identities = Identities(files) + m.SetViolations, m.PolicyIDRules = authoredSites(files, m.Authored) return m, nil } From b8c4aa20a410f950e63164a9c24e4f5e045b898b Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:12 -0300 Subject: [PATCH 24/47] feat(reconciler): identity checks, plugin paths and the plugin library gate (R75-R77, R79) R75: across all of a plugin's policy paths, a policy_id declared twice is duplicate-policy-id and one evidence identity loaded from two paths is duplicate-policy-identity: errors when the overlay touches the plugin's policies or a bundle involved, warnings from the file (R34). The R66 package warning remains for the rest. R76/R79: after prefetch the agent reads each plugin binary's agent library version from its build info (internal/pluginlib, memoized) and reports plugins[] with lib-version and inline-policies. An overlay that gives inline policies to a plugin older than pluginlib.MinInlinePolicy (v0.9.0) is rejected with plugin-lib-inline-unsupported, naming set-form violations separately for plugins older than v0.7.1. Unknown versions (replace/devel builds) and file bundles only warn. R77: policy-bundles[].plugin-path is the exact path plugins receive. --- cmd/agent.go | 22 +++ cmd/compat.go | 147 +++++++++++++++++++ cmd/compat_test.go | 176 ++++++++++++++++++++++ cmd/identity.go | 209 +++++++++++++++++++++++++++ cmd/inline.go | 97 ++----------- cmd/inline_test.go | 102 +++++++++++-- cmd/reconciler.go | 23 ++- cmd/report.go | 1 + internal/pluginlib/pluginlib.go | 122 ++++++++++++++++ internal/pluginlib/pluginlib_test.go | 66 +++++++++ 10 files changed, 866 insertions(+), 99 deletions(-) create mode 100644 cmd/compat.go create mode 100644 cmd/compat_test.go create mode 100644 cmd/identity.go create mode 100644 internal/pluginlib/pluginlib.go create mode 100644 internal/pluginlib/pluginlib_test.go diff --git a/cmd/agent.go b/cmd/agent.go index bdfe055..aaa0cab 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -31,6 +31,7 @@ import ( "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" @@ -376,6 +377,14 @@ func agentRunner(cmd *cobra.Command, args []string) error { rc.resolvePolicy = func(ctx context.Context, source string) (string, error) { return ar.downloadPolicy(ctx, source, logger) } + pluginLibs := &pluginlib.Cache{} + rc.pluginLib = func(ctx context.Context, source string) (string, error) { + binary, err := ar.downloadPlugin(ctx, source, logger) + if err != nil { + return "", err + } + return pluginLibs.Version(binary) + } rc.onStartupFailure = ar.ReportStartupFailure active, err := rc.startup(context.Background()) @@ -2131,6 +2140,19 @@ func (ar *AgentRunner) ReportStartupFailure(ctx context.Context, cfg *agentConfi } // downloadPolicy fetches one policy source into the shared policy cache. +// downloadPlugin returns the plugin binary of source for this platform, downloading it into +// the shared cache when it is not there yet (as runs and Prefetch do). +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)) +} + func (ar *AgentRunner) downloadPolicy(ctx context.Context, source string, logger hclog.Logger) (string, error) { if logger == nil { logger = hclog.NewNullLogger() diff --git a/cmd/compat.go b/cmd/compat.go new file mode 100644 index 0000000..c65f460 --- /dev/null +++ b/cmd/compat.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/agent/internal/pluginlib" + "github.com/compliance-framework/api/pkg/agentconfig" +) + +// Plugin compatibility (R76, R79). A plugin evaluates policies with the policy-manager it +// embeds, so what it can do with an inline bundle depends on the agent library it was built +// with, which the agent reads from the plugin binary's build info: +// +// - inline policies need pluginlib.MinInlinePolicy, the first library that seeds evidence +// with policy_id (R74). An overlay that gives a plugin built on an older library an +// inline bundle, or changes one it uses, is rejected (plugin-lib-inline-unsupported), +// so the last good configuration keeps running (R79); +// - a set-form violation (`violation contains ...`) crashes plugins older than +// pluginlib.MinViolationSet, which is named separately when it applies, with the fix; +// - inline bundles from the config file only warn (R34), and so does a library whose +// version is unknown (a local or replaced build, or no build info), so local plugin +// builds keep working. For file bundles a policy_id that the plugin ignores is named per +// module (plugin-lib-policy-id-unsupported); for overlay bundles the gate supersedes it. + +// Shorter names for the plugins[].inline-policies values (R79). +const ( + inlinePoliciesSupported = agentconfig.InlinePoliciesSupported + inlinePoliciesUnsupported = agentconfig.InlinePoliciesUnsupported + inlinePoliciesUnknown = agentconfig.InlinePoliciesUnknown +) + +// 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) + +// inlineSupport classifies a plugin's agent library version for inline policies. +func inlineSupport(version string) string { + ok, known := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy) + switch { + case !known: + return inlinePoliciesUnknown + case ok: + return inlinePoliciesSupported + default: + return inlinePoliciesUnsupported + } +} + +// pluginCompatibility returns the R76/R79 problems of the plugins of runtime that use inline +// bundles, and the plugins report. touched are the pointers the overlay changed. Without a +// pluginLib function (tests) it checks and reports nothing. +func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentConfig, materialized map[string]*inlinepolicy.Materialized, touched []string) ([]agentconfig.PolicyError, []agentconfig.PluginReport) { + if rc.pluginLib == nil || runtime == nil { + return nil, nil + } + var problems []agentconfig.PolicyError + var reports []agentconfig.PluginReport + for _, name := range sortedMapKeys(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; treating it as unknown", "plugin", name, "source", p.Source, "error", err) + } + } + support := inlineSupport(version) + reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version, InlinePolicies: support}) + if support == inlinePoliciesSupported { + continue + } + + var bundles []*inlinepolicy.Materialized + seen := map[string]bool{} + for _, e := range p.Policies { + if b, ok := agentconfig.InlineBundleName(string(e)); ok && materialized[b] != nil && !seen[b] { + seen[b] = true + bundles = append(bundles, materialized[b]) + } + } + if len(bundles) == 0 { + continue + } + problems = append(problems, libProblems(name, version, support, bundles, rc.overlayTouchesInline(name, bundles, touched))...) + } + return problems, reports +} + +// overlayTouchesInline reports whether the overlay changed the plugin's policies or one of +// the inline bundles it uses. +func (rc *reconciler) overlayTouchesInline(plugin string, bundles []*inlinepolicy.Materialized, touched []string) bool { + if touchedByOverlay(agentconfig.Pointer("plugins", plugin, "policies"), touched) { + return true + } + for _, m := range bundles { + if touchedByOverlay(agentconfig.Pointer("policy_bundles", m.Name), touched) { + return true + } + } + return false +} + +// libProblems are the problems of one plugin whose library is not known to support inline +// policies (support is unsupported or unknown). +func libProblems(plugin, version, support string, bundles []*inlinepolicy.Materialized, overlay bool) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + lib := version + if lib == "" { + lib = "unknown" + } + severity := agentconfig.SeverityWarning + if support == inlinePoliciesUnsupported && overlay { + severity = agentconfig.SeverityError + } + setOK, setKnown := pluginlib.AtLeast(version, pluginlib.MinViolationSet) + for _, m := range bundles { + msg := fmt.Sprintf("plugin %s (agent lib %s) doesn't support inline policies; upgrade the plugin to a build on agent ≥ %s", plugin, lib, pluginlib.MinInlinePolicy) + if support == inlinePoliciesUnknown { + msg = fmt.Sprintf("plugin %s: its agent library version is unknown (a local or replaced build, or no build info), so the agent cannot tell whether it supports inline policies; plugins built on agent < %s ignore policy_id, and those < %s crash on `violation contains ...`", + plugin, pluginlib.MinInlinePolicy, pluginlib.MinViolationSet) + } + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Severity: severity, Code: agentconfig.PolicyCodePluginLibInlineUnsupported, Message: msg}) + + if !setOK { + sev := severity + if !setKnown { + sev = agentconfig.SeverityWarning + } + for _, s := range m.SetViolations { + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: sev, + Code: agentconfig.PolicyCodePluginLibViolationSetUnsupported, + Message: fmt.Sprintf("plugin %s (agent lib %s) cannot evaluate violation as a set (`violation contains ...` needs agent ≥ %s) and would crash; use `violation[{...}] if { … }`", + plugin, lib, pluginlib.MinViolationSet)}) + } + } + if support == inlinePoliciesUnsupported && !overlay { + for _, s := range m.PolicyIDRules { + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: agentconfig.SeverityWarning, + Code: agentconfig.PolicyCodePluginLibPolicyIDUnsupported, + Message: fmt.Sprintf("plugin %s (agent lib %s) ignores policy_id; this module starts a new evidence stream", plugin, lib)}) + } + } + } + return out +} diff --git a/cmd/compat_test.go b/cmd/compat_test.go new file mode 100644 index 0000000..5cdbc75 --- /dev/null +++ b/cmd/compat_test.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "context" + "strings" + "testing" + + "github.com/compliance-framework/agent/internal/pluginlib" + "github.com/compliance-framework/api/pkg/agentconfig" +) + +// Plugin compatibility (R76, R79): the plugin's agent library decides whether it may use +// inline policies. + +// withPluginLib makes the harness's plugins report version as their agent library. +func withPluginLib(h *remoteHarness, version string) { + h.rc.pluginLib = func(context.Context, string) (string, error) { return version, nil } +} + +// inlineOverlay changes the inline bundle ssh, which plugin ssh uses (an overlay-introduced +// inline policy). +const inlineOverlay = `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\ntitle := \"extra v2\"\n\nviolation contains {\"id\": \"x\"} if input.max > data.max\n"}}}}` + +func policyErrorsWithCode(r agentconfig.Report, code string) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + for _, e := range r.PolicyErrors { + if e.Code == code { + out = append(out, e) + } + } + return out +} + +// rejectionErrors are the report's errors: a rejected revision's report also carries the +// running configuration's warnings. +func rejectionErrors(r agentconfig.Report, code string) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + for _, e := range policyErrorsWithCode(r, code) { + if e.Severity == agentconfig.SeverityError { + out = append(out, e) + } + } + return out +} + +func TestCompat_OldLibRejectsOverlayInlineBundle_R79(t *testing.T) { + h, _ := newInlineHarness(t) + withPluginLib(h, "v0.1.9-0.20250708121809-c5059c3efac8") + h.remote.publish(1, inlineOverlay) + active := mustStartup(t, h.rc) + if active.overlay != nil { + t.Fatal("an inline bundle for a plugin that cannot honour it must not be applied") + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors { + t.Fatalf("expected rejected/policy-errors, got %s/%s", r.Status, r.Reason) + } + gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported) + if len(gate) != 1 || gate[0].Bundle != "ssh" || + !strings.Contains(gate[0].Message, "plugin ssh (agent lib v0.1.9-0.20250708121809-c5059c3efac8) doesn't support inline policies") || + !strings.Contains(gate[0].Message, pluginlib.MinInlinePolicy) { + t.Fatalf("expected one plugin-lib-inline-unsupported error naming the plugin, its lib and the minimum, got %+v", r.PolicyErrors) + } + // The more specific set-form problem is named too, with its fix. + set := rejectionErrors(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported) + if len(set) != 1 || set[0].Path != "extra.rego" || set[0].Row == 0 || + !strings.Contains(set[0].Message, "violation[{...}] if") { + t.Fatalf("expected a located set-form violation error, got %+v", r.PolicyErrors) + } + // The gate supersedes the policy_id warning for overlay bundles. + if got := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibPolicyIDUnsupported); len(got) != 0 { + t.Fatalf("no policy_id warning expected next to the gate, got %+v", got) + } + if len(r.Plugins) != 1 || r.Plugins[0].Name != "ssh" || r.Plugins[0].Source != "ghcr.io/compliance-framework/plugin-ssh:v1" || + r.Plugins[0].LibVersion != "v0.1.9-0.20250708121809-c5059c3efac8" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesUnsupported { + t.Fatalf("plugins report = %+v", r.Plugins) + } +} + +func TestCompat_AssigningInlineBundleToOldLibIsRejected_R79(t *testing.T) { + h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1"]`, 1)) + withPluginLib(h, "v0.7.2") + h.remote.publish(1, `{"plugins":{"ssh":{"policies":["inline:ssh"]}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay != nil || r.Status != agentconfig.StatusRejected { + t.Fatalf("assigning an inline bundle to a v0.7.2 plugin must be rejected, got %s/%s", r.Status, r.Reason) + } + if gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported); len(gate) != 1 { + t.Fatalf("expected the plugin-lib-inline-unsupported error, got %+v", r.PolicyErrors) + } + // v0.7.2 evaluates set-form violations: no set-form error. + if set := rejectionErrors(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported); len(set) != 0 { + t.Fatalf("no set-form error expected for v0.7.2, got %+v", set) + } +} + +func TestCompat_SupportedLibApplies_R79(t *testing.T) { + h, _ := newInlineHarness(t) + withPluginLib(h, "v0.9.0") + h.remote.publish(1, inlineOverlay) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay == nil || r.Status != agentconfig.StatusApplied { + t.Fatalf("a supported plugin must apply the revision, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + } + for _, code := range []string{agentconfig.PolicyCodePluginLibInlineUnsupported, agentconfig.PolicyCodePluginLibViolationSetUnsupported, agentconfig.PolicyCodePluginLibPolicyIDUnsupported} { + if got := policyErrorsWithCode(r, code); len(got) != 0 { + t.Fatalf("no %s expected for a supported plugin, got %+v", code, got) + } + } + if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "v0.9.0" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { + t.Fatalf("plugins report = %+v", r.Plugins) + } +} + +func TestCompat_UnknownLibAppliesWithWarnings_R79(t *testing.T) { + // A local build with a replace (the RC stack's plugin-local-ssh) or "(devel)": the + // library version is unknown. + h, _ := newInlineHarness(t) + withPluginLib(h, "") + h.remote.publish(1, inlineOverlay) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay == nil || r.Status != agentconfig.StatusApplied { + t.Fatalf("an unknown library must not block, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + } + gate := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibInlineUnsupported) + if len(gate) != 1 || gate[0].Severity != agentconfig.SeverityWarning || !strings.Contains(gate[0].Message, "unknown") { + t.Fatalf("expected a plugin-lib-inline-unsupported warning, got %+v", r.PolicyErrors) + } + set := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported) + if len(set) != 1 || set[0].Severity != agentconfig.SeverityWarning { + t.Fatalf("expected a set-form warning, got %+v", r.PolicyErrors) + } + if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesUnknown { + t.Fatalf("plugins report = %+v", r.Plugins) + } +} + +func TestCompat_FileInlineBundleOnOldLibWarns_R79(t *testing.T) { + h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, " title := \"extra\"\n", " policy_id := \"extra\"\n\n title := \"extra\"\n", 1)) + withPluginLib(h, "v0.7.2") + active := mustStartup(t, h.rc) + if active == nil { + t.Fatal("a file-defined inline bundle must still load") + } + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable { + t.Fatalf("file-origin problems only warn, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + } + gate := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibInlineUnsupported) + if len(gate) != 1 || gate[0].Severity != agentconfig.SeverityWarning { + t.Fatalf("expected a plugin-lib-inline-unsupported warning, got %+v", r.PolicyErrors) + } + ids := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibPolicyIDUnsupported) + if len(ids) != 1 || ids[0].Severity != agentconfig.SeverityWarning || ids[0].Path != "extra.rego" { + t.Fatalf("expected a plugin-lib-policy-id-unsupported warning for the file bundle, got %+v", r.PolicyErrors) + } +} + +func TestInlineSupport(t *testing.T) { + for version, want := range map[string]string{ + "v0.9.0": inlinePoliciesSupported, + "v0.9.0-rc1": inlinePoliciesSupported, + "v0.8.0": inlinePoliciesUnsupported, + "v0.7.1": inlinePoliciesUnsupported, + "": inlinePoliciesUnknown, + "(devel)": inlinePoliciesUnknown, + "v0.0.0-20261001110117-f88bde9ee37a": inlinePoliciesUnknown, + } { + if got := inlineSupport(version); got != want { + t.Errorf("inlineSupport(%q) = %s, want %s", version, got, want) + } + } +} diff --git a/cmd/identity.go b/cmd/identity.go new file mode 100644 index 0000000..ea45605 --- /dev/null +++ b/cmd/identity.go @@ -0,0 +1,209 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/api/pkg/agentconfig" +) + +// Evidence identity across a plugin's policy paths (R66, R75). A plugin evaluates each of +// its policy paths separately and records evidence for every policy module of each, so a +// policy loaded twice is reported twice: +// +// - duplicate-policy-id: two modules declare the same policy_id; +// - duplicate-policy-identity: two modules from different policy paths have the same +// evidence identity: the same seed (a policy_id that continues the stream of a module +// loaded too), or, without policy_id, the same package and bundle-relative file (an +// inline bundle listed next to the source it extends); +// - duplicate-policy-package (R66): the same package from two policy paths otherwise. +// +// The first two are errors when the overlay introduces them (it changes the plugin's +// policies or one of the inline bundles involved) and warnings when they come from the +// config file (R34). The last is always a warning. Plugins that use no inline bundle are not +// checked: their policy paths are the file's and the vendors' business. + +// codeDuplicatePolicyPackage is the PolicyError code of R66. +const codeDuplicatePolicyPackage = "duplicate-policy-package" + +// loadedModule is one policy module a plugin loads. +type loadedModule struct { + entry string // the plugin's policy entry + bundle string // the inline bundle's name, "" for other sources + pluginPath string // the path the agent passes the plugin for entry + id inlinepolicy.ModuleIdentity + authored bool +} + +// seed is the module's evidence seed (policy_file, _policy_path). +func (m loadedModule) seed() (string, string) { + return inlinepolicy.SeedOf(m.id, m.pluginPath) +} + +func (m loadedModule) where() string { + return fmt.Sprintf("%s in %s", m.id.Path, m.entry) +} + +// policyIdentities returns the R66/R75 problems of every enabled plugin that uses an inline +// bundle. touched are the pointers the overlay changed. A source that cannot be resolved +// here is skipped (prefetch reports it). +func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig.Config, skip map[string]string, materialized map[string]*inlinepolicy.Materialized, touched []string) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + for _, pluginName := range sortedPluginNames(resolved.Plugins) { + p := resolved.Plugins[pluginName] + if p == nil || !p.IsEnabled() { + continue + } + if _, skipped := skip[pluginName]; skipped { + continue + } + var modules []loadedModule + usesInline := false + seenEntry := map[string]bool{} + for _, e := range p.Policies { + entry := string(e) + if seenEntry[entry] { + continue + } + seenEntry[entry] = true + if name, ok := agentconfig.InlineBundleName(e); ok { + m := materialized[name] + if m == nil { + continue + } + usesInline = true + for _, id := range m.Identities { + modules = append(modules, loadedModule{entry: entry, bundle: name, pluginPath: m.Path, id: id, authored: m.Authored[id.Path]}) + } + continue + } + dir, ids, err := rc.sourceIdentities(ctx, entry) + if err != nil { + continue + } + for _, id := range ids { + modules = append(modules, loadedModule{entry: entry, pluginPath: dir, id: id}) + } + } + if !usesInline { + continue + } + pluginTouched := touchedByOverlay(agentconfig.Pointer("plugins", pluginName, "policies"), touched) + severity := func(a, b loadedModule) string { + for _, m := range []loadedModule{a, b} { + if pluginTouched || (m.bundle != "" && touchedByOverlay(agentconfig.Pointer("policy_bundles", m.bundle), touched)) { + return agentconfig.SeverityError + } + } + return agentconfig.SeverityWarning + } + out = append(out, identityProblems(pluginName, modules, severity)...) + } + return out +} + +// identityProblems compares every pair of modules of one plugin. +func identityProblems(pluginName string, modules []loadedModule, severity func(a, b loadedModule) string) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + identityPackages := map[string]bool{} // packages with an identity problem across paths + samePackage := map[string][]loadedModule{} + report := func(a, b loadedModule, sev, code, msg string) { + at := b + if at.bundle == "" || (a.bundle != "" && a.authored && !b.authored) { + at = a + } + out = append(out, agentconfig.PolicyError{Bundle: at.bundle, Path: at.id.Path, Severity: sev, Code: code, + Message: fmt.Sprintf("plugin %s: %s", pluginName, msg)}) + } + for j, b := range modules { + for _, a := range modules[:j] { + sameEntry := a.entry == b.entry + switch { + case a.id.PolicyID != "" && a.id.PolicyID == b.id.PolicyID: + // Within one source, two vendor modules are the vendor's business, and two + // authored ones are the API's contract check (CheckContract). + if sameEntry && a.authored == b.authored { + continue + } + if !sameEntry { + identityPackages[a.id.Package], identityPackages[b.id.Package] = true, true + } + report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyID, + fmt.Sprintf("policy_id %q is declared by both %s and %s, so their evidence shares one stream; give each policy its own policy_id", a.id.PolicyID, a.where(), b.where())) + case sameEntry: + continue + case a.id.Package == b.id.Package && sameSeed(a, b): + identityPackages[a.id.Package] = true + report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyIdentity, + fmt.Sprintf("%s and %s write to the same evidence stream (package %s), so each evidence is recorded twice; load only one of them: if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", a.where(), b.where(), a.id.Package)) + case a.id.Package == b.id.Package && a.id.PolicyID == "" && b.id.PolicyID == "" && a.id.Path == b.id.Path: + identityPackages[a.id.Package] = true + report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyIdentity, + fmt.Sprintf("%s is loaded from both %s and %s (package %s), so the plugin records its evidence twice, in two streams; load only one of them: if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", a.id.Path, a.entry, b.entry, a.id.Package)) + case a.id.Package == b.id.Package: + samePackage[a.id.Package] = append(samePackage[a.id.Package], a, b) + } + } + } + for _, pkg := range sortedMapKeys(samePackage) { + if identityPackages[pkg] { + continue + } + var at loadedModule + var entries []string + seen := map[string]bool{} + for _, m := range samePackage[pkg] { + if !seen[m.entry] { + seen[m.entry] = true + entries = append(entries, m.entry) + } + if at.bundle == "" && m.bundle != "" { + at = m + } + } + if at.entry == "" { + at = samePackage[pkg][0] + } + out = append(out, agentconfig.PolicyError{ + Bundle: at.bundle, + Path: at.id.Path, + Severity: agentconfig.SeverityWarning, + Code: codeDuplicatePolicyPackage, + Message: fmt.Sprintf("plugin %s: package %s is defined in more than one of its policy paths (%s), so the plugin records its evidence more than once; if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", + pluginName, pkg, strings.Join(entries, ", ")), + }) + } + return out +} + +func sameSeed(a, b loadedModule) bool { + af, ap := a.seed() + bf, bp := b.seed() + return af == bf && ap == bp +} + +// sourceIdentities resolves an OCI or local policy source and returns the path plugins +// receive for it and its modules' identities. OCI trees are memoized per (source, dir). +func (rc *reconciler) sourceIdentities(ctx context.Context, source string) (string, []inlinepolicy.ModuleIdentity, error) { + dir, _, err := rc.sourceInventory(ctx, source) + if err != nil { + return "", nil, err + } + key := source + "\x00" + dir + if ids, ok := rc.identityMemo[key]; ok { + return dir, ids, nil + } + ids, err := inlinepolicy.TreeIdentities(dir) + if err != nil { + return "", nil, err + } + if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { + if len(rc.identityMemo) >= artifactMemoLimit { + rc.identityMemo = map[string][]inlinepolicy.ModuleIdentity{} + } + rc.identityMemo[key] = ids + } + return dir, ids, nil +} diff --git a/cmd/inline.go b/cmd/inline.go index 220b9a2..04bdef1 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -3,7 +3,6 @@ package cmd import ( "context" "errors" - "fmt" "path/filepath" "sort" "strings" @@ -11,7 +10,6 @@ import ( "github.com/compliance-framework/agent/internal/inlinepolicy" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/pkg/agentconfig/regocheck" - "github.com/compliance-framework/api/pkg/policyeval" ) // inlineGCKeepPerBundle is how many materialized versions of each bundle GC keeps besides the @@ -28,7 +26,7 @@ func (rc *reconciler) inlineRoot() string { // way the plugin will load it. Any error rejects the revision (policy-errors); warnings are // kept for the report. No network is touched before the Classify gate: extends trees are // fetched here, after it. -func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Config, skip map[string]string) (inlineResult, *applyError) { +func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Config, skip map[string]string, touched []string) (inlineResult, *applyError) { var res inlineResult if len(resolved.PolicyBundles) == 0 { return res, nil @@ -106,14 +104,18 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co })...) } } + for _, name := range sortedMaterializedKeys(materialized) { + problems = append(problems, inlinepolicy.OverrideStreams(materialized[name])...) + } + problems = append(problems, rc.policyIdentities(ctx, resolved, skip, materialized, touched)...) if agentconfig.HasPolicyErrors(problems) { return res, policyRejection(append(problems, res.warnings...)) } res.warnings = append(res.warnings, problems...) - res.warnings = append(res.warnings, rc.duplicatePackages(ctx, resolved, skip, materialized)...) res.warnings = dedupePolicyErrors(res.warnings) agentconfig.SortPolicyErrors(res.warnings) + res.materialized = materialized res.dirs = map[string]string{} res.trees = map[string]string{} res.digests = map[string]string{} @@ -124,10 +126,11 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co res.trees[entry] = m.Dir res.digests[entry] = m.Digest res.reports = append(res.reports, agentconfig.PolicyBundleReport{ - Source: entry, - Digest: m.Digest, - Extends: m.Extends, - Files: m.Files, + Source: entry, + Digest: m.Digest, + Extends: m.Extends, + Files: m.Files, + PluginPath: m.Path, }) res.artifacts = append(res.artifacts, artifactTree{digest: m.Digest, dir: m.Dir}) if m.Extends != nil { @@ -137,82 +140,6 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co return res, nil } -// duplicatePackages warns when a plugin that uses an inline bundle loads the same policy -// package from two of its policy paths, typically a vendor source and an inline bundle that -// extends it without replacing it (R66): the plugin then records evidence for that package -// twice. A path that cannot be resolved here is skipped (prefetch reports it). -func (rc *reconciler) duplicatePackages(ctx context.Context, resolved agentconfig.Config, skip map[string]string, materialized map[string]*inlinepolicy.Materialized) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - for _, pluginName := range sortedPluginNames(resolved.Plugins) { - p := resolved.Plugins[pluginName] - if p == nil || !p.IsEnabled() || len(p.Policies) < 2 { - continue - } - if _, skipped := skip[pluginName]; skipped { - continue - } - type origin struct { - entry, bundle, file string - } - byPackage := map[string][]origin{} - usesInline := false - for _, e := range p.Policies { - var files []agentconfig.PolicyFileReport - bundle := "" - if name, ok := agentconfig.InlineBundleName(e); ok { - m := materialized[name] - if m == nil { - continue - } - usesInline, bundle, files = true, name, m.Files - } else { - _, r, err := rc.sourceInventory(ctx, string(e)) - if err != nil { - continue - } - files = r.Files - } - seen := map[string]bool{} - for _, f := range files { - if f.Package == "" || seen[f.Package] || !policyeval.IsPolicyPackage(f.Package) || policyeval.IsTestFile(f.Path) { - continue - } - seen[f.Package] = true - byPackage[f.Package] = append(byPackage[f.Package], origin{entry: string(e), bundle: bundle, file: f.Path}) - } - } - if !usesInline { - continue - } - for _, pkg := range sortedMapKeys(byPackage) { - origins := byPackage[pkg] - if len(origins) < 2 { - continue - } - var at origin - entries := make([]string, len(origins)) - for i, o := range origins { - entries[i] = o.entry - if at.bundle == "" && o.bundle != "" { - at = o - } - } - out = append(out, agentconfig.PolicyError{ - Bundle: at.bundle, - Path: at.file, - Severity: agentconfig.SeverityWarning, - Code: codeDuplicatePolicyPackage, - Message: fmt.Sprintf("plugin %s: package %s is defined in more than one of its policy paths (%s), so the plugin records its evidence more than once; if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", - pluginName, pkg, strings.Join(entries, ", ")), - }) - } - } - return out -} - -// codeDuplicatePolicyPackage is the PolicyError code of R66. -const codeDuplicatePolicyPackage = "duplicate-policy-package" - // dedupePolicyErrors drops exact duplicates (the per-plugin checks of one bundle repeat // plugin-independent findings) and a warning superseded by an error with the same bundle, // path and code (the API's static check may only warn about what the agent, which sees the @@ -269,6 +196,8 @@ func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) ( if err != nil { continue } + // The resolver returns the exact path string plugins receive for the source (R77). + r.PluginPath = dir reports = append(reports, r) trees = append(trees, artifactTree{digest: r.Digest, dir: dir}) } diff --git a/cmd/inline_test.go b/cmd/inline_test.go index 2bd4de9..a074081 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -42,12 +43,19 @@ policy_bundles: ` func newInlineHarness(t *testing.T) (*remoteHarness, string) { + t.Helper() + return newInlineHarnessWith(t, inlineBaseConfig) +} + +// newInlineHarnessWith is newInlineHarness on config, which may use the vendor source +// ghcr.io/vendor/policies:v1. +func newInlineHarnessWith(t *testing.T, config string) (*remoteHarness, string) { t.Helper() vendor := t.TempDir() if err := os.WriteFile(filepath.Join(vendor, "banner.rego"), []byte("package compliance_framework.banner\n\nimport rego.v1\n\nviolation contains {\"remarks\": \"b\"} if not input.banner\n"), 0o644); err != nil { t.Fatal(err) } - h := newRemoteHarness(t, inlineBaseConfig) + h := newRemoteHarness(t, config) h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { if source == "ghcr.io/vendor/policies:v1" { return vendor, nil @@ -165,41 +173,109 @@ func TestInline_GCAfterSwap(t *testing.T) { } } -// TestInline_DuplicatePackageAcrossPolicyPaths_R66: a plugin that loads both the vendor -// source and an inline bundle extending it gets a warning naming both paths; the revision -// still applies. -func TestInline_DuplicatePackageAcrossPolicyPaths_R66(t *testing.T) { - h, vendor := newInlineHarness(t) - h.writeConfig(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1", "inline:ssh"]`, 1)) - h.rc = h.newReconciler() - h.rc.resolvePolicy = func(context.Context, string) (string, error) { return vendor, nil } +// TestInline_DuplicateIdentityAcrossPolicyPaths_R75: a plugin that loads both the vendor +// source and an inline bundle extending it from the config file gets a warning naming both +// paths (R34: file problems only warn); the revision still applies. +func TestInline_DuplicateIdentityAcrossPolicyPaths_R75(t *testing.T) { + h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1", "inline:ssh"]`, 1)) mustStartup(t, h.rc) r := h.remote.lastReport(t) if r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable { - t.Fatalf("a duplicate package is a warning only, got %s/%s", r.Status, r.Reason) + t.Fatalf("a duplicate from the file is a warning only, got %s/%s", r.Status, r.Reason) } var found bool for _, e := range r.PolicyErrors { - if e.Code == codeDuplicatePolicyPackage { + if e.Code == agentconfig.PolicyCodeDuplicatePolicyIdentity { found = e.Severity == agentconfig.SeverityWarning && e.Bundle == "ssh" && e.Path == "banner.rego" && strings.Contains(e.Message, "compliance_framework.banner") && strings.Contains(e.Message, "ghcr.io/vendor/policies:v1") && strings.Contains(e.Message, "inline:ssh") } + if e.Code == codeDuplicatePolicyPackage { + t.Fatalf("the identity problem replaces the package warning for the same package: %+v", e) + } } if !found { - t.Fatalf("expected a duplicate-policy-package warning naming both paths, got %+v", r.PolicyErrors) + t.Fatalf("expected a duplicate-policy-identity warning naming both paths, got %+v", r.PolicyErrors) } // Replacing the source with the inline bundle (the R22 swap) clears it. h, _ = newInlineHarness(t) mustStartup(t, h.rc) for _, e := range h.remote.lastReport(t).PolicyErrors { - if e.Code == codeDuplicatePolicyPackage { + if e.Code == agentconfig.PolicyCodeDuplicatePolicyIdentity || e.Code == codeDuplicatePolicyPackage { t.Fatalf("no warning expected without the duplicate path: %+v", e) } } } +// TestInline_OverlayDuplicateIdentityRejected_R75: an overlay that lists the inline bundle +// next to the source it extends is rejected. +func TestInline_OverlayDuplicateIdentityRejected_R75(t *testing.T) { + h, _ := newInlineHarness(t) + h.remote.publish(1, `{"plugins":{"ssh":{"policies":["ghcr.io/vendor/policies:v1","inline:ssh"]}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay != nil || r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors { + t.Fatalf("expected rejected/policy-errors, got %s/%s", r.Status, r.Reason) + } + if errs := rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyIdentity); len(errs) != 1 || errs[0].Path != "banner.rego" { + t.Fatalf("expected one duplicate-policy-identity error, got %+v", r.PolicyErrors) + } +} + +// TestInline_PolicyIDIdentities_R75: a policy_id that continues the vendor stream collides +// with the vendor module when both are loaded; a policy_id declared twice across a plugin's +// paths is a duplicate-policy-id. +func TestInline_PolicyIDIdentities_R75(t *testing.T) { + t.Run("continuing id next to the vendor source", func(t *testing.T) { + h, vendor := newInlineHarness(t) + // The policy_id the UI writes to continue the vendor stream: /. + override := fmt.Sprintf("package compliance_framework.banner\n\nimport rego.v1\n\npolicy_id := %q\n\ntitle := \"banner\"\n\nviolation[{\"id\": \"b\"}] if not input.banner\n", vendor+"/banner.rego") + src, _ := json.Marshal(override) + h.remote.publish(1, `{"plugins":{"ssh":{"policies":["ghcr.io/vendor/policies:v1","inline:ssh"]}},"policy_bundles":{"ssh":{"modules":{"banner.rego":`+string(src)+`}}}}`) + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + errs := rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyIdentity) + if r.Status != agentconfig.StatusRejected || len(errs) != 1 || errs[0].Path != "banner.rego" || !strings.Contains(errs[0].Message, "same evidence stream") { + t.Fatalf("expected a same-stream duplicate-policy-identity error, got %s %+v", r.Status, r.PolicyErrors) + } + }) + t.Run("same policy_id in two paths", func(t *testing.T) { + h, vendor := newInlineHarness(t) + if err := os.WriteFile(filepath.Join(vendor, "motd.rego"), []byte("package compliance_framework.motd\n\nimport rego.v1\n\npolicy_id := \"shared\"\n\ntitle := \"motd\"\n"), 0o644); err != nil { + t.Fatal(err) + } + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\npolicy_id := \"shared\"\n\ntitle := \"extra\"\n"}}}}`) + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + errs := rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyID) + if r.Status != agentconfig.StatusRejected || len(errs) != 1 || errs[0].Path != "extra.rego" || !strings.Contains(errs[0].Message, `"shared"`) { + t.Fatalf("expected a duplicate-policy-id error at the authored module, got %s %+v", r.Status, r.PolicyErrors) + } + }) +} + +// TestInline_ReportsPluginPaths_R77: each policy bundle report carries the exact path the +// agent passes plugins for it. +func TestInline_ReportsPluginPaths_R77(t *testing.T) { + h, vendor := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["inline:ssh"] + other: + source: ghcr.io/compliance-framework/plugin-other:v1 + policies: ["ghcr.io/vendor/policies:v1"]`, 1)) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + got := map[string]string{} + for _, b := range r.PolicyBundles { + got[b.Source] = b.PluginPath + } + if got["inline:ssh"] != active.runtime.inlinePolicyDirs["inline:ssh"] || !strings.HasSuffix(got["inline:ssh"], filepath.Join("inline", "ssh", "current", "bundle")) { + t.Fatalf("inline plugin-path = %q, want the stable path %q", got["inline:ssh"], active.runtime.inlinePolicyDirs["inline:ssh"]) + } + if got["ghcr.io/vendor/policies:v1"] != vendor { + t.Fatalf("OCI plugin-path = %q, want %q", got["ghcr.io/vendor/policies:v1"], vendor) + } +} + // TestInline_StablePathSwapsOnlyBetweenRuns_R67: a revision prepared while a run is in // progress does not move the stable path under it; the run loop points it at the new tree // before the next run, and back at the previous tree when it falls back. diff --git a/cmd/reconciler.go b/cmd/reconciler.go index b8b9358..2ec235d 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -63,6 +63,8 @@ type candidate struct { trees []artifactTree warnings []agentconfig.FieldError // R34 file-origin warnings policyWarnings []agentconfig.PolicyError // G3b severity=warning + // plugins are the runtime's plugins with their agent library versions (R76, R79). + plugins []agentconfig.PluginReport } // appliedRevision is the overlay revision the candidate applies, or nil for the file only. @@ -204,8 +206,13 @@ type reconciler struct { // resolvePolicy returns the policy root of an OCI or local policy source (downloading it // into the shared cache); it serves inline bundles' extends and the report inventory. resolvePolicy inlinepolicy.Resolver - // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"). + // pluginLib reads the agent library version of a prefetched plugin source (R76, R79); + // nil skips the plugin compatibility checks and report. + pluginLib pluginLibFunc + // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"), and + // identityMemo their modules' evidence identities. inventoryMemo map[string]agentconfig.PolicyBundleReport + identityMemo map[string][]inlinepolicy.ModuleIdentity // artifacts is the process-wide artifact uploader (shared with the plugins' API helpers). artifacts *runnerpkg.ArtifactUploader // artifactMemo maps a policy tree digest to the digest of its uploaded artifact ("" when @@ -263,6 +270,7 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor loggedOnce: map[string]bool{}, inventoryMemo: map[string]agentconfig.PolicyBundleReport{}, + identityMemo: map[string][]inlinepolicy.ModuleIdentity{}, artifacts: runnerpkg.NewArtifactUploader(), artifactMemo: map[string]string{}, } @@ -583,7 +591,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent } } - inline, aerr := rc.prepareInline(ctx, resolved, part.skip) + inline, aerr := rc.prepareInline(ctx, resolved, part.skip, touched) if aerr != nil { return nil, aerr } @@ -602,6 +610,14 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent aerr.runtime = runtime return nil, aerr } + compat, plugins := rc.pluginCompatibility(ctx, runtime, inline.materialized, touched) + if agentconfig.HasPolicyErrors(compat) { + return nil, policyRejection(append(compat, inline.warnings...)) + } + if len(compat) > 0 { + inline.warnings = dedupePolicyErrors(append(inline.warnings, compat...)) + agentconfig.SortPolicyErrors(inline.warnings) + } if rcfg.Mode != agentconfig.ModeOff { reports, trees := rc.sourceReports(ctx, runtime) inline.reports = append(inline.reports, reports...) @@ -627,6 +643,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent trees: inline.artifacts, warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), policyWarnings: inline.warnings, + plugins: plugins, }, nil } @@ -638,6 +655,8 @@ type inlineResult struct { reports []agentconfig.PolicyBundleReport artifacts []artifactTree warnings []agentconfig.PolicyError + // materialized are the bundles the enabled plugins use, by name. + materialized map[string]*inlinepolicy.Materialized } // overlayTouched returns the pointers an overlay changed, computed on the unresolved forms so diff --git a/cmd/report.go b/cmd/report.go index 401ca2f..54d6b9f 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -119,6 +119,7 @@ func (rc *reconciler) buildReport(active *candidate, outcome *applyError, rcfg a PolicyBundles: rc.withArtifactDigests(active.bundles), Warnings: active.warnings, RemoteConfig: &rcfg, + Plugins: active.plugins, } report.PolicyErrors = append(report.PolicyErrors, active.policyWarnings...) switch { diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go new file mode 100644 index 0000000..a467d39 --- /dev/null +++ b/internal/pluginlib/pluginlib.go @@ -0,0 +1,122 @@ +// Package pluginlib reads which version of this module (the agent library) a plugin binary +// was built with, and decides what the plugin supports (R76, R79). +// +// Plugins evaluate policies with the policy-manager they embed, so what a plugin can do with +// 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" + + "golang.org/x/mod/module" + "golang.org/x/mod/semver" +) + +// AgentModule is the module path plugins import for runner and policy-manager. +const AgentModule = "github.com/compliance-framework/agent" + +// Minimum agent library versions. +const ( + // MinViolationSet is the first agent library whose policy-manager accepts violation as a + // set (`violation contains {...}`, agent#86). Older plugins expect an object + // (`violation[{...}] if { ... }`) and crash on a set. + MinViolationSet = "v0.7.1" + // MinInlinePolicy is the first agent library release with policy_id seeding (R74), and + // so the first whose plugins may use inline policy bundles (R79). It also covers + // MinViolationSet. No release has it yet: v0.8.0 is being cut from main without R74 + // (v0.8.0-rc4 is the latest tag), so it is the next minor version, v0.9.0. Its + // pre-releases (v0.9.0-rc1, ...) count. Update it if R74 ships in another release. + MinInlinePolicy = "v0.9.0" +) + +// 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 +} + +// AtLeast reports whether version is min or later. known is false when version is not a +// version that can be compared ("" for unknown, "(devel)", a pseudo-version with no tag +// before it): then ok is false too. A pseudo-version counts as the tagged version it was +// built after (v0.7.2-0.2026…-abc is v0.7.1 plus unreleased commits, which may not include +// what min added). Pre-releases of min count as min. +func AtLeast(version, min string) (ok, known bool) { + base := version + if module.IsPseudoVersion(version) { + var err error + if base, err = module.PseudoVersionBase(version); err != nil || base == "" { + return false, false + } + } + if !semver.IsValid(base) { + return false, false + } + // "-0" is the lowest pre-release of min, so min's release candidates count. + floor := min + if semver.Prerelease(min) == "" { + floor = semver.Canonical(min) + "-0" + } + return semver.Compare(base, floor) >= 0, true +} + +// 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..cda6c0b --- /dev/null +++ b/internal/pluginlib/pluginlib_test.go @@ -0,0 +1,66 @@ +package pluginlib + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAtLeast(t *testing.T) { + cases := []struct { + version, min string + ok, known bool + }{ + {"v0.7.1", MinViolationSet, true, true}, + {"v0.7.2", MinViolationSet, true, true}, + {"v0.7.0", MinViolationSet, false, true}, + {"v0.1.9", MinViolationSet, false, true}, + {"v0.1.9-0.20250101000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.1.8 + {"v0.7.2-0.20260601000000-abcdefabcdef", MinViolationSet, true, true}, // after v0.7.1 + {"v0.7.1-0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.0, before v0.7.1 + {"v0.8.0-rc4", MinInlinePolicy, false, true}, + {"v0.8.0", MinInlinePolicy, false, true}, + {"v0.8.1-0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.8.0: R74 not guaranteed + {"v0.9.0-rc1", MinInlinePolicy, true, true}, + {"v0.9.0-rc1.0.20261002000000-abcdefabcdef", MinInlinePolicy, true, true}, + {"v0.9.0", MinInlinePolicy, true, true}, + {"v1.0.0", MinInlinePolicy, true, true}, + {"", MinInlinePolicy, false, false}, + {"(devel)", MinInlinePolicy, false, false}, + {"v0.0.0-20261001000000-abcdefabcdef", MinInlinePolicy, false, false}, // no tag before it + {"garbage", MinInlinePolicy, false, false}, + } + for _, tc := range cases { + ok, known := AtLeast(tc.version, tc.min) + assert.Equal(t, tc.ok, ok, "%s >= %s", tc.version, tc.min) + assert.Equal(t, tc.known, known, "%s known", tc.version) + } + ok, known := AtLeast(MinInlinePolicy, MinViolationSet) + assert.True(t, ok && known, "MinInlinePolicy must cover MinViolationSet") +} + +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) +} From 7d5a84e43fc182e14a862f17cad8b4de3efdc0c6 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:00:12 -0300 Subject: [PATCH 25/47] docs: policy identity and plugin compatibility (R74-R79) configuration.md: policy_id seeding, lifecycle, the plugin rebuild requirement, the R75 identity codes, plugin-path and the library gate. ADR 0003 records the identity and gate decisions; policy_artifacts.md the seed; AGENTS.md the rules that keep existing evidence streams intact. --- AGENTS.md | 5 ++ docs/adr/0003-remote-config-overlay.md | 30 +++++++++- docs/configuration.md | 83 +++++++++++++++++++++++++- docs/policy_artifacts.md | 9 +++ 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4752fc9..45e497e 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 the seed of a policy without `policy_id`; `policy_id` changes it only through the + API's `policyeval.SeedPath`. The golden test in `policy-manager/policy_id_test.go` pins the old UUIDs. +- **Plugin library gate.** Inline policies need a plugin built on agent ≥ `pluginlib.MinInlinePolicy` + (`internal/pluginlib`). Set it to the first release that ships `policy_id` seeding. - **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/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 0047135..df112ce 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -62,7 +62,7 @@ executes on the agent host while a revision is checked (D17); a vendor test that Residual risk (R20): a vendor rule that already calls `http.send` with a URL taken from `data` makes `policy_data` edits to that plugin effectively able to direct its requests. Eval-time capabilities are a follow-up. -### Policy contract: the agent is authoritative (R63, R65, R66) +### Policy contract: the agent is authoritative (R63, R65) The API's `regocheck` runs `policyeval.CheckContract` on the authored modules only (it lacks the extends trees). The agent, after the compile and the tests pass, adds what needs the whole tree: the static check on the vendor-only @@ -71,8 +71,32 @@ and `policy-manager`'s `GetRiskTemplates`, sandboxed like the tests. Decode erro `PolicyError`s with the contract codes: errors for packages that contain an authored non-test module, warnings for vendor-only packages; conflicts that only show on `{}` and input-dependent titles are warnings. A package that fails to evaluate is left out of the next attempt, so one broken package does not hide the others. A compile error in a vendor file whose -package an authored module also defines carries an override hint (R65). A package defined in two of a plugin's policy -paths is a warning naming both (R66). The per-plugin results are de-duplicated before they are reported. +package an authored module also defines carries an override hint (R65). The per-plugin results are de-duplicated before they are reported. + +### Evidence identity across a plugin's paths (R66, R74, R75) + +`policy-manager` seeds evidence UUIDs from the policy's package, file and plugin path, and, when the module declares +one, from its `policy_id` through `policyeval.SeedPath` (the API's function, so the API, the agent and the UI agree). +Without a `policy_id` the seed is byte for byte the old one: we never change the identity of an existing stream. +The agent checks identity statically, with the same rule the API's contract check enforces (`policy_id := +""`, once per package), over every module of every policy path of each plugin that uses an inline bundle: +the same `policy_id` twice is `duplicate-policy-id`, and the same identity from two paths (equal seeds, or the same +package and bundle-relative file without `policy_id`) is `duplicate-policy-identity`. Both are errors when the overlay +touches the plugin's `policies` or a bundle involved, warnings otherwise (R34); what is left of R66 (the same package +with different identities) stays a warning. For an `extends` bundle, an override is compared with the vendor module it +replaces by the seeds plugins would compute from the extends source's path and the bundle's path: a changed +`package` is `policy-package-changed`, any other difference `policy-stream-forked` (warnings). + +### Plugin library gate (R76, R79) + +What a plugin can do 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 gates inline policies on +`pluginlib.MinInlinePolicy` (v0.9.0: no release has R74 yet and v0.8.0 is being cut without it; update the constant +if that changes). We chose a hard gate over per-feature warnings because a plugin that ignores `policy_id` silently +forks every overridden stream, and one older than v0.7.1 crashes on set-form violations. Only overlay-introduced +inline policies are rejected; file bundles and unknown versions (a `replace` or devel build, which local development +relies on) warn. A pseudo-version counts as its base tag, since an untagged commit after v0.8.x may not contain R74. ### Stable inline paths (R67) diff --git a/docs/configuration.md b/docs/configuration.md index 247102f..b07ba9c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -277,9 +277,21 @@ policy_bundles: - **Vendor tests that no longer compile (R65)** reject the revision: plugins compile `_test.rego` files too, so such a bundle would produce no evidence at all. When the failing file is a vendor file in a package an authored module also defines, the error carries a hint: keep the rule the override removed, or add the test to `delete`. -- **Duplicate evidence (R66).** When a plugin lists both a source and an inline bundle that `extends` it, every - vendor package is evaluated twice and recorded twice. The agent reports a warning naming both paths; replace the - source with the inline bundle instead. +- **Duplicate evidence (R66, R75).** A plugin evaluates each of its policy paths separately, so a policy it loads + twice is recorded twice. Across all of a plugin's policy paths the agent reports: + - `duplicate-policy-id`: two modules declare the same `policy_id`; + - `duplicate-policy-identity`: two paths load the same evidence identity, either the same package and + bundle-relative file without `policy_id` (typically a source listed next to an inline bundle that `extends` it), + or a `policy_id` that continues the stream of a module the plugin also loads; + - `duplicate-policy-package` (warning): the same package from two paths otherwise. + + The first two are **errors** when the overlay introduces them (it changes the plugin's `policies` or one of the + bundles involved) and warnings when they come from the file (R34). Replace the source with the inline bundle + instead of listing both. +- **Overrides and evidence streams (R75).** Overriding a vendor module keeps its evidence stream only if the + override keeps the vendor module's `package` and continues its identity (see "Policy identity" below). A changed + `package` is a `policy-package-changed` warning; a `policy_id` that does not continue the vendor stream (missing, + removed or changed) is a `policy-stream-forked` warning, which names the `policy_id` that would continue it. - **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any `.rego` file fails with `download-failed`. - **Where bundles live (R67).** Inline bundles are written under the state directory and are never downloaded. Each @@ -291,6 +303,10 @@ policy_bundles: plugins receive the digest directory itself and evidence identity changes with each revision, as before. Directories no running, pending or fallback configuration uses are garbage-collected, never the one `current` points to. +- **Plugin paths (R77).** Each `policy-bundles[]` entry of the configuration report carries `plugin-path`, the exact + path string the agent passes plugins for that source: the stable path for an inline bundle, and the path the + agent extracted an OCI source to, or a local source as configured. It is what a continuity `policy_id` is built + from. - **Sources for the UI (R62).** Outside mode `off`, the agent uploads every policy tree it reports (each inline bundle, the tree it extends, and each OCI or local source a plugin uses) as a policy bundle artifact, and reports its `artifact-digest` next to the tree digest, so the UI can show and pre-fill vendor sources. These are the same @@ -299,6 +315,67 @@ policy_bundles: `artifact-digest` empty and never rejects or fails a revision. Note that artifacts are readable with `artifact:read`. +### Policy identity (`policy_id`, R74) + +Plugins seed each evidence UUID with the policy's package, its file (the plugin path joined with the module's path in +the bundle) and their `_policy_path` label (the plugin path). So moving a policy (an override in an inline bundle, a +new OCI tag, a renamed bundle, a moved state directory) starts a new evidence stream. A module may declare its +identity instead: + +```rego +package compliance_framework.ssh_deny_password_auth + +import rego.v1 + +policy_id := "ssh-deny-password-auth" +``` + +- **Without `policy_id` nothing changes**: the seed is exactly what it has always been. +- **With one**, `policy-manager` seeds with `policyeval.SeedPath`: the `policy_file` seed is the `policy_id`, and the + `_policy_path` seed (when the plugin labels it) is the `policy_id` minus the module's bundle-relative path when it + ends in `/`, else the `policy_id` itself. Evidence keeps its real `_policy_path` label and gains `_policy_id`. +- **Continue a stream** with `policy_id := "/"`: the old location reproduces the old UUID, so an + override keeps writing to the vendor policy's stream. The UI pre-fills it from the report's `plugin-path`. + (A local source configured with a non-clean path such as `./policies` is the exception for plugins that label + `_policy_path`: their seed keeps the literal `./policies`, which a `policy_id` does not reproduce.) +- **A stable stream**: any other `policy_id` (for example `/`) does not depend on where the bundle + lives. +- `policy_id` must be `policy_id := ""`, declared once per package, at most 512 characters + (`invalid-policy-id` otherwise), and unique among the policies a plugin loads. + +| Change | Evidence stream | +|---|---| +| override a policy, keeping its `policy_id` (or the continuity `policy_id`) | the same stream | +| `delete` the policy | the stream stops receiving evidence | +| revert the override | the same stream | +| a new policy | a new stream, from its `policy_id` | +| publish it into a real bundle with the same `policy_id` | the same stream | +| change the overridden module's `package` | a new stream (`policy-package-changed`) | + +**Plugins must be rebuilt.** Plugins seed evidence with the `policy-manager` they embed, so `policy_id` only takes +effect for plugins built on an agent library that includes it (agent ≥ v0.9.0, `pluginlib.MinInlinePolicy`). + +### Plugin compatibility (R76, R79) + +The agent reads each plugin's agent library version from the binary's Go build info, without starting it, and +reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: `supported`, `unsupported` or +`unknown`). + +- **Inline policies need agent ≥ v0.9.0** (the first release with `policy_id`; it also covers set-form violations). + An overlay that gives an `inline:` bundle to a plugin built on an older library, or changes a bundle such a plugin + uses, is rejected before it is applied with `plugin-lib-inline-unsupported` ("plugin `

` (agent lib ``) doesn't + support inline policies; upgrade the plugin to a build on agent ≥ v0.9.0"); the running configuration keeps + running. +- **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect + `violation[{...}] if { ... }`. An authored module that uses them for such a plugin is also named + (`plugin-lib-violation-set-unsupported`), with that fix. +- **Unknown versions are warnings**: a `replace`d or `(devel)` build, a pseudo-version with no tag before it, or a + binary without build info. Local plugin builds therefore keep working. +- **File-defined inline bundles only warn** (R34), and for them each authored `policy_id` the plugin would ignore is a + `plugin-lib-policy-id-unsupported` warning ("this module starts a new evidence stream"). +- A pseudo-version counts as the tag it was built after, so a plugin built on an unreleased commit after v0.8.x is + `unsupported` until it moves to a v0.9.0 build (or a `replace`, which is `unknown`). + ## Remote configuration An agent with `api.auth` credentials can pick up a configuration overlay stored in the API. The `remote_config` block diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 5a746e2..d6f1b41 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -36,6 +36,15 @@ serves the configuration report, which uploads the policy trees it names (see `configuration.md`, "Sources for the UI"); a tree uploaded there is not uploaded again for evidence, and both produce the same artifact digest. +## Evidence identity + +`GenerateResults` seeds each evidence UUID with the policy's package, its file and the plugin's labels (including +`_policy_path`, the path the agent passed the plugin). When the module declares `policy_id`, `policyeval.SeedPath` +replaces the file and `_policy_path` seed values, so the stream follows the policy rather than where its bundle lives; +the evidence keeps its real `_policy_path` label and gains a `_policy_id` label. Without a `policy_id` the seed is +unchanged. See `configuration.md`, "Policy identity". Plugins get this by rebuilding on an agent library that +includes it. + ## Plugins Plugins need no code changes. A plugin gets this by moving to an agent version that From 3e367b266e57cdd2faee605558d71b38c36dd54a Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:02:39 -0300 Subject: [PATCH 26/47] self-review: address round-3 pass 1 findings - Prefetch fetches plugins through downloadPlugin, so the library check reads the binary Prefetch fetched; restore downloadPolicy's doc comment. - R79: an overlay that moves a plugin with inline bundles to another build (its source) introduces the incompatibility too. - Compute each module's evidence seed once in the identity checks. --- cmd/agent.go | 11 ++++------- cmd/compat.go | 16 ++++++++++------ cmd/compat_test.go | 22 ++++++++++++++++++++++ cmd/identity.go | 17 +++++++++-------- docs/configuration.md | 4 ++-- 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/cmd/agent.go b/cmd/agent.go index aaa0cab..0088203 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -2073,10 +2073,6 @@ func (ar *AgentRunner) Prefetch(ctx context.Context, cfg *agentConfig) error { if logger == nil { logger = hclog.NewNullLogger() } - platform := v1.Platform{ - Architecture: runtime.GOARCH, - OS: runtime.GOOS, - } pluginSources := map[string]struct{}{} policySources := map[string]struct{}{} for _, pluginConfig := range cfg.Plugins { @@ -2089,7 +2085,7 @@ func (ar *AgentRunner) Prefetch(ctx context.Context, cfg *agentConfig) error { } } for _, source := range sortedSetKeys(pluginSources) { - if _, err := ar.download(ctx, source, AgentPluginDir, "plugin", platformDownloadKey(platform), logger, remote.WithPlatform(platform)); err != nil { + if _, err := ar.downloadPlugin(ctx, source, logger); err != nil { return &downloadError{source: source, err: err} } } @@ -2139,9 +2135,9 @@ func (ar *AgentRunner) ReportStartupFailure(ctx context.Context, cfg *agentConfi } } -// downloadPolicy fetches one policy source into the shared policy cache. // downloadPlugin returns the plugin binary of source for this platform, downloading it into -// the shared cache when it is not there yet (as runs and Prefetch do). +// the shared plugin cache when it is not there yet. Prefetch uses it, so the reconciler's +// plugin library check (R76) reads the binary Prefetch fetched. func (ar *AgentRunner) downloadPlugin(ctx context.Context, source string, logger hclog.Logger) (string, error) { if logger == nil { logger = hclog.NewNullLogger() @@ -2153,6 +2149,7 @@ func (ar *AgentRunner) downloadPlugin(ctx context.Context, source string, logger 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() diff --git a/cmd/compat.go b/cmd/compat.go index c65f460..8885ba2 100644 --- a/cmd/compat.go +++ b/cmd/compat.go @@ -15,8 +15,9 @@ import ( // // - inline policies need pluginlib.MinInlinePolicy, the first library that seeds evidence // with policy_id (R74). An overlay that gives a plugin built on an older library an -// inline bundle, or changes one it uses, is rejected (plugin-lib-inline-unsupported), -// so the last good configuration keeps running (R79); +// inline bundle, changes one it uses, or moves a plugin that uses one to such a build +// is rejected (plugin-lib-inline-unsupported), so the last good configuration keeps +// running (R79); // - a set-form violation (`violation contains ...`) crashes plugins older than // pluginlib.MinViolationSet, which is named separately when it applies, with the fix; // - inline bundles from the config file only warn (R34), and so does a library whose @@ -88,11 +89,14 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon return problems, reports } -// overlayTouchesInline reports whether the overlay changed the plugin's policies or one of -// the inline bundles it uses. +// overlayTouchesInline reports whether the overlay brought the plugin and its inline +// policies together: it changed the plugin's policies or source (a different plugin build), +// or one of the inline bundles the plugin uses. func (rc *reconciler) overlayTouchesInline(plugin string, bundles []*inlinepolicy.Materialized, touched []string) bool { - if touchedByOverlay(agentconfig.Pointer("plugins", plugin, "policies"), touched) { - return true + for _, field := range []string{"policies", "source"} { + if touchedByOverlay(agentconfig.Pointer("plugins", plugin, field), touched) { + return true + } } for _, m := range bundles { if touchedByOverlay(agentconfig.Pointer("policy_bundles", m.Name), touched) { diff --git a/cmd/compat_test.go b/cmd/compat_test.go index 5cdbc75..294f3c9 100644 --- a/cmd/compat_test.go +++ b/cmd/compat_test.go @@ -174,3 +174,25 @@ func TestInlineSupport(t *testing.T) { } } } + +// TestCompat_OverlayMovingAnInlinePluginToAnOldBuildIsRejected_R79: the file gives the +// plugin an inline bundle; an overlay that switches the plugin to an older build introduces +// the incompatibility. +func TestCompat_OverlayMovingAnInlinePluginToAnOldBuildIsRejected_R79(t *testing.T) { + h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, "mode: apply_safe", "mode: apply_safe\n trusted_sources: [\"ghcr.io/compliance-framework/*\"]", 1)) + h.rc.pluginLib = func(_ context.Context, source string) (string, error) { + if source == "ghcr.io/compliance-framework/plugin-ssh:v0" { + return "v0.7.2", nil + } + return "v0.9.0", nil + } + h.remote.publish(1, `{"plugins":{"ssh":{"source":"ghcr.io/compliance-framework/plugin-ssh:v0"}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay != nil || r.Status != agentconfig.StatusRejected { + t.Fatalf("moving an inline plugin to an old build must be rejected, got %s/%s %s", r.Status, r.Reason, derefString(r.Error)) + } + if gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported); len(gate) != 1 || !strings.Contains(gate[0].Message, "v0.7.2") { + t.Fatalf("expected the plugin-lib-inline-unsupported error, got %+v", r.PolicyErrors) + } +} diff --git a/cmd/identity.go b/cmd/identity.go index ea45605..e3018fb 100644 --- a/cmd/identity.go +++ b/cmd/identity.go @@ -35,11 +35,14 @@ type loadedModule struct { pluginPath string // the path the agent passes the plugin for entry id inlinepolicy.ModuleIdentity authored bool + // seedFile and seedPath are the module's evidence seed (policy_file, _policy_path). + seedFile, seedPath string } -// seed is the module's evidence seed (policy_file, _policy_path). -func (m loadedModule) seed() (string, string) { - return inlinepolicy.SeedOf(m.id, m.pluginPath) +func newLoadedModule(entry, bundle, pluginPath string, id inlinepolicy.ModuleIdentity, authored bool) loadedModule { + m := loadedModule{entry: entry, bundle: bundle, pluginPath: pluginPath, id: id, authored: authored} + m.seedFile, m.seedPath = inlinepolicy.SeedOf(id, pluginPath) + return m } func (m loadedModule) where() string { @@ -75,7 +78,7 @@ func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig } usesInline = true for _, id := range m.Identities { - modules = append(modules, loadedModule{entry: entry, bundle: name, pluginPath: m.Path, id: id, authored: m.Authored[id.Path]}) + modules = append(modules, newLoadedModule(entry, name, m.Path, id, m.Authored[id.Path])) } continue } @@ -84,7 +87,7 @@ func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig continue } for _, id := range ids { - modules = append(modules, loadedModule{entry: entry, pluginPath: dir, id: id}) + modules = append(modules, newLoadedModule(entry, "", dir, id, false)) } } if !usesInline { @@ -179,9 +182,7 @@ func identityProblems(pluginName string, modules []loadedModule, severity func(a } func sameSeed(a, b loadedModule) bool { - af, ap := a.seed() - bf, bp := b.seed() - return af == bf && ap == bp + return a.seedFile == b.seedFile && a.seedPath == b.seedPath } // sourceIdentities resolves an OCI or local policy source and returns the path plugins diff --git a/docs/configuration.md b/docs/configuration.md index b07ba9c..7487d8b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -362,8 +362,8 @@ reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: ` `unknown`). - **Inline policies need agent ≥ v0.9.0** (the first release with `policy_id`; it also covers set-form violations). - An overlay that gives an `inline:` bundle to a plugin built on an older library, or changes a bundle such a plugin - uses, is rejected before it is applied with `plugin-lib-inline-unsupported` ("plugin `

` (agent lib ``) doesn't + An overlay that gives an `inline:` bundle to a plugin built on an older library, changes a bundle such a plugin + uses, or moves a plugin that uses one to such a build (its `source`), is rejected before it is applied with `plugin-lib-inline-unsupported` ("plugin `

` (agent lib ``) doesn't support inline policies; upgrade the plugin to a build on agent ≥ v0.9.0"); the running configuration keeps running. - **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect From 2586322b7d37dbebd8630cb0872dddb887820e40 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:04:41 -0300 Subject: [PATCH 27/47] self-review: address round-3 pass 2 findings - Tell overlay-introduced identity and plugin library problems by what the overlay brings (a policy entry the file does not give the plugin, a changed bundle, a changed plugin source), not by any touch of the plugin's policies, so an overlay never fails on the file's own problems (R34). - Name an untagged plugin library version in the unknown-version warning. --- cmd/compat.go | 36 ++++++--------- cmd/compat_test.go | 9 ++++ cmd/identity.go | 61 +++++++++++++++++++++----- cmd/inline.go | 4 +- cmd/inline_test.go | 24 ++++++++++ cmd/reconciler.go | 5 ++- docs/adr/0003-remote-config-overlay.md | 3 +- docs/configuration.md | 7 +-- 8 files changed, 109 insertions(+), 40 deletions(-) diff --git a/cmd/compat.go b/cmd/compat.go index 8885ba2..5ea413b 100644 --- a/cmd/compat.go +++ b/cmd/compat.go @@ -50,9 +50,9 @@ func inlineSupport(version string) string { } // pluginCompatibility returns the R76/R79 problems of the plugins of runtime that use inline -// bundles, and the plugins report. touched are the pointers the overlay changed. Without a +// bundles, and the plugins report. origin tells overlay-introduced problems apart. Without a // pluginLib function (tests) it checks and reports nothing. -func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentConfig, materialized map[string]*inlinepolicy.Materialized, touched []string) ([]agentconfig.PolicyError, []agentconfig.PluginReport) { +func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentConfig, materialized map[string]*inlinepolicy.Materialized, origin policyOrigin) ([]agentconfig.PolicyError, []agentconfig.PluginReport) { if rc.pluginLib == nil || runtime == nil { return nil, nil } @@ -75,37 +75,25 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon var bundles []*inlinepolicy.Materialized seen := map[string]bool{} + // The overlay brought the plugin and its inline policies together when it changed the + // plugin's source (a different build), gave it an inline entry, or changed a bundle + // it uses. + introduced := origin.pluginTouched(name, "source") for _, e := range p.Policies { if b, ok := agentconfig.InlineBundleName(string(e)); ok && materialized[b] != nil && !seen[b] { seen[b] = true bundles = append(bundles, materialized[b]) + introduced = introduced || origin.newEntry(name, string(e)) || origin.bundleTouched(b) } } if len(bundles) == 0 { continue } - problems = append(problems, libProblems(name, version, support, bundles, rc.overlayTouchesInline(name, bundles, touched))...) + problems = append(problems, libProblems(name, version, support, bundles, introduced)...) } return problems, reports } -// overlayTouchesInline reports whether the overlay brought the plugin and its inline -// policies together: it changed the plugin's policies or source (a different plugin build), -// or one of the inline bundles the plugin uses. -func (rc *reconciler) overlayTouchesInline(plugin string, bundles []*inlinepolicy.Materialized, touched []string) bool { - for _, field := range []string{"policies", "source"} { - if touchedByOverlay(agentconfig.Pointer("plugins", plugin, field), touched) { - return true - } - } - for _, m := range bundles { - if touchedByOverlay(agentconfig.Pointer("policy_bundles", m.Name), touched) { - return true - } - } - return false -} - // libProblems are the problems of one plugin whose library is not known to support inline // policies (support is unsupported or unknown). func libProblems(plugin, version, support string, bundles []*inlinepolicy.Materialized, overlay bool) []agentconfig.PolicyError { @@ -122,8 +110,12 @@ func libProblems(plugin, version, support string, bundles []*inlinepolicy.Materi for _, m := range bundles { msg := fmt.Sprintf("plugin %s (agent lib %s) doesn't support inline policies; upgrade the plugin to a build on agent ≥ %s", plugin, lib, pluginlib.MinInlinePolicy) if support == inlinePoliciesUnknown { - msg = fmt.Sprintf("plugin %s: its agent library version is unknown (a local or replaced build, or no build info), so the agent cannot tell whether it supports inline policies; plugins built on agent < %s ignore policy_id, and those < %s crash on `violation contains ...`", - plugin, pluginlib.MinInlinePolicy, pluginlib.MinViolationSet) + why := "is unknown (a local or replaced build, or no build info)" + if version != "" { + why = fmt.Sprintf("%s has no release before it", version) + } + msg = fmt.Sprintf("plugin %s: its agent library version %s, so the agent cannot tell whether it supports inline policies; plugins built on agent < %s ignore policy_id, and those < %s crash on `violation contains ...`", + plugin, why, pluginlib.MinInlinePolicy, pluginlib.MinViolationSet) } out = append(out, agentconfig.PolicyError{Bundle: m.Name, Severity: severity, Code: agentconfig.PolicyCodePluginLibInlineUnsupported, Message: msg}) diff --git a/cmd/compat_test.go b/cmd/compat_test.go index 294f3c9..724ec39 100644 --- a/cmd/compat_test.go +++ b/cmd/compat_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/compliance-framework/agent/internal/inlinepolicy" "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/api/pkg/agentconfig" ) @@ -159,6 +160,14 @@ func TestCompat_FileInlineBundleOnOldLibWarns_R79(t *testing.T) { } } +func TestLibProblemsNameAnUntaggedVersion(t *testing.T) { + m := &inlinepolicy.Materialized{Name: "ssh"} + got := libProblems("ssh", "v0.0.0-20261001110117-f88bde9ee37a", inlinePoliciesUnknown, []*inlinepolicy.Materialized{m}, true) + if len(got) != 1 || got[0].Severity != agentconfig.SeverityWarning || !strings.Contains(got[0].Message, "v0.0.0-20261001110117-f88bde9ee37a has no release before it") { + t.Fatalf("an untagged build only warns and names its version, got %+v", got) + } +} + func TestInlineSupport(t *testing.T) { for version, want := range map[string]string{ "v0.9.0": inlinePoliciesSupported, diff --git a/cmd/identity.go b/cmd/identity.go index e3018fb..0aa0f09 100644 --- a/cmd/identity.go +++ b/cmd/identity.go @@ -20,11 +20,55 @@ import ( // inline bundle listed next to the source it extends); // - duplicate-policy-package (R66): the same package from two policy paths otherwise. // -// The first two are errors when the overlay introduces them (it changes the plugin's -// policies or one of the inline bundles involved) and warnings when they come from the -// config file (R34). The last is always a warning. Plugins that use no inline bundle are not +// The first two are errors when the overlay introduces them (it gives the plugin one of the +// policy entries involved, or changes one of the inline bundles involved) and warnings when +// they come from the config file (R34). The last is always a warning. Plugins that use no inline bundle are not // checked: their policy paths are the file's and the vendors' business. +// policyOrigin tells overlay-introduced policy problems from file-origin ones (R34). +type policyOrigin struct { + // touched are the pointers the overlay changed (nil without an overlay). + touched []string + // filePolicies are the policy entries each plugin has in the config file. + filePolicies map[string]map[string]bool +} + +func newPolicyOrigin(file agentconfig.Config, touched []string) policyOrigin { + o := policyOrigin{touched: touched, filePolicies: map[string]map[string]bool{}} + for name, p := range file.Plugins { + if p == nil { + continue + } + entries := map[string]bool{} + for _, e := range p.Policies { + entries[string(e)] = true + } + o.filePolicies[name] = entries + } + return o +} + +// newEntry reports whether the overlay gave plugin the policy entry: the file does not. +func (o policyOrigin) newEntry(plugin, entry string) bool { + return o.touched != nil && !o.filePolicies[plugin][entry] +} + +// bundleTouched reports whether the overlay changed inline bundle name. +func (o policyOrigin) bundleTouched(name string) bool { + return touchedByOverlay(agentconfig.Pointer("policy_bundles", name), o.touched) +} + +// pluginTouched reports whether the overlay changed field of plugin. +func (o policyOrigin) pluginTouched(plugin, field string) bool { + return touchedByOverlay(agentconfig.Pointer("plugins", plugin, field), o.touched) +} + +// introduces reports whether the overlay brought module m to plugin: it added m's policy +// entry to the plugin, or changed m's inline bundle. +func (o policyOrigin) introduces(plugin string, m loadedModule) bool { + return o.newEntry(plugin, m.entry) || (m.bundle != "" && o.bundleTouched(m.bundle)) +} + // codeDuplicatePolicyPackage is the PolicyError code of R66. const codeDuplicatePolicyPackage = "duplicate-policy-package" @@ -50,9 +94,9 @@ func (m loadedModule) where() string { } // policyIdentities returns the R66/R75 problems of every enabled plugin that uses an inline -// bundle. touched are the pointers the overlay changed. A source that cannot be resolved +// bundle. origin tells overlay-introduced problems apart. A source that cannot be resolved // here is skipped (prefetch reports it). -func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig.Config, skip map[string]string, materialized map[string]*inlinepolicy.Materialized, touched []string) []agentconfig.PolicyError { +func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig.Config, skip map[string]string, materialized map[string]*inlinepolicy.Materialized, origin policyOrigin) []agentconfig.PolicyError { var out []agentconfig.PolicyError for _, pluginName := range sortedPluginNames(resolved.Plugins) { p := resolved.Plugins[pluginName] @@ -93,12 +137,9 @@ func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig if !usesInline { continue } - pluginTouched := touchedByOverlay(agentconfig.Pointer("plugins", pluginName, "policies"), touched) severity := func(a, b loadedModule) string { - for _, m := range []loadedModule{a, b} { - if pluginTouched || (m.bundle != "" && touchedByOverlay(agentconfig.Pointer("policy_bundles", m.bundle), touched)) { - return agentconfig.SeverityError - } + if origin.introduces(pluginName, a) || origin.introduces(pluginName, b) { + return agentconfig.SeverityError } return agentconfig.SeverityWarning } diff --git a/cmd/inline.go b/cmd/inline.go index 04bdef1..8b9362f 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -26,7 +26,7 @@ func (rc *reconciler) inlineRoot() string { // way the plugin will load it. Any error rejects the revision (policy-errors); warnings are // kept for the report. No network is touched before the Classify gate: extends trees are // fetched here, after it. -func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Config, skip map[string]string, touched []string) (inlineResult, *applyError) { +func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Config, skip map[string]string, origin policyOrigin) (inlineResult, *applyError) { var res inlineResult if len(resolved.PolicyBundles) == 0 { return res, nil @@ -107,7 +107,7 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co for _, name := range sortedMaterializedKeys(materialized) { problems = append(problems, inlinepolicy.OverrideStreams(materialized[name])...) } - problems = append(problems, rc.policyIdentities(ctx, resolved, skip, materialized, touched)...) + problems = append(problems, rc.policyIdentities(ctx, resolved, skip, materialized, origin)...) if agentconfig.HasPolicyErrors(problems) { return res, policyRejection(append(problems, res.warnings...)) } diff --git a/cmd/inline_test.go b/cmd/inline_test.go index a074081..9b661ad 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -367,3 +367,27 @@ func TestInline_EvidenceSourceIsTheBundle(t *testing.T) { t.Fatalf("OCI source = %+v", oci) } } + +// TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75: an overlay that only reorders a +// plugin's policies does not introduce the file's duplicate, so it still applies. +func TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75(t *testing.T) { + h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1", "inline:ssh"]`, 1)) + withPluginLib(h, "v0.7.2") // file bundles on an old plugin only warn too + h.remote.publish(1, `{"plugins":{"ssh":{"policies":["inline:ssh","ghcr.io/vendor/policies:v1"]}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay == nil || r.Status != agentconfig.StatusApplied { + t.Fatalf("expected the reorder to apply, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + } + for _, code := range []string{agentconfig.PolicyCodeDuplicatePolicyIdentity, agentconfig.PolicyCodePluginLibInlineUnsupported} { + got := policyErrorsWithCode(r, code) + if len(got) == 0 { + t.Fatalf("expected a %s warning, got %+v", code, r.PolicyErrors) + } + for _, e := range got { + if e.Severity != agentconfig.SeverityWarning { + t.Fatalf("%s must stay a warning: %+v", code, e) + } + } + } +} diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 2ec235d..8012076 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -591,7 +591,8 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent } } - inline, aerr := rc.prepareInline(ctx, resolved, part.skip, touched) + origin := newPolicyOrigin(base.declared, touched) + inline, aerr := rc.prepareInline(ctx, resolved, part.skip, origin) if aerr != nil { return nil, aerr } @@ -610,7 +611,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent aerr.runtime = runtime return nil, aerr } - compat, plugins := rc.pluginCompatibility(ctx, runtime, inline.materialized, touched) + compat, plugins := rc.pluginCompatibility(ctx, runtime, inline.materialized, origin) if agentconfig.HasPolicyErrors(compat) { return nil, policyRejection(append(compat, inline.warnings...)) } diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index df112ce..ef1329c 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -82,7 +82,8 @@ The agent checks identity statically, with the same rule the API's contract chec ""`, once per package), over every module of every policy path of each plugin that uses an inline bundle: the same `policy_id` twice is `duplicate-policy-id`, and the same identity from two paths (equal seeds, or the same package and bundle-relative file without `policy_id`) is `duplicate-policy-identity`. Both are errors when the overlay -touches the plugin's `policies` or a bundle involved, warnings otherwise (R34); what is left of R66 (the same package +introduces them (it gives the plugin a policy entry the file does not, or changes a bundle involved), warnings +otherwise (R34), so an overlay never fails on the file's own duplicates; what is left of R66 (the same package with different identities) stays a warning. For an `extends` bundle, an override is compared with the vendor module it replaces by the seeds plugins would compute from the extends source's path and the bundle's path: a changed `package` is `policy-package-changed`, any other difference `policy-stream-forked` (warnings). diff --git a/docs/configuration.md b/docs/configuration.md index 7487d8b..ed40900 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -285,8 +285,9 @@ policy_bundles: or a `policy_id` that continues the stream of a module the plugin also loads; - `duplicate-policy-package` (warning): the same package from two paths otherwise. - The first two are **errors** when the overlay introduces them (it changes the plugin's `policies` or one of the - bundles involved) and warnings when they come from the file (R34). Replace the source with the inline bundle + The first two are **errors** when the overlay introduces them (it gives the plugin one of the policy entries + involved, or changes one of the bundles involved) and warnings when they come from the file (R34), even under an + overlay that changes something else. Replace the source with the inline bundle instead of listing both. - **Overrides and evidence streams (R75).** Overriding a vendor module keeps its evidence stream only if the override keeps the vendor module's `package` and continues its identity (see "Policy identity" below). A changed @@ -362,7 +363,7 @@ reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: ` `unknown`). - **Inline policies need agent ≥ v0.9.0** (the first release with `policy_id`; it also covers set-form violations). - An overlay that gives an `inline:` bundle to a plugin built on an older library, changes a bundle such a plugin + An overlay that gives an `inline:` entry to a plugin built on an older library, changes a bundle such a plugin uses, or moves a plugin that uses one to such a build (its `source`), is rejected before it is applied with `plugin-lib-inline-unsupported` ("plugin `

` (agent lib ``) doesn't support inline policies; upgrade the plugin to a build on agent ≥ v0.9.0"); the running configuration keeps running. From 5b1204291b6c4f347490198ccf8370a04c977f32 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:27:08 -0300 Subject: [PATCH 28/47] chore: pin api f50e4d8; gate inline policies on agent v0.8.0 final (R80); literal continuity ids --- AGENTS.md | 3 +- cmd/compat_test.go | 21 +++++----- docs/adr/0003-remote-config-overlay.md | 6 +-- docs/configuration.md | 30 ++++++++------ go.mod | 2 +- go.sum | 4 +- internal/inlinepolicy/identity.go | 9 ++-- internal/inlinepolicy/identity_test.go | 34 +++++++++++++++ internal/pluginlib/pluginlib.go | 18 ++++---- internal/pluginlib/pluginlib_test.go | 10 +++-- policy-manager/policy_id_test.go | 57 +++++++++++++++----------- 11 files changed, 125 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45e497e..f95383a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,8 @@ change here must keep working with them. field compute it. Never change the seed of a policy without `policy_id`; `policy_id` changes it only through the API's `policyeval.SeedPath`. The golden test in `policy-manager/policy_id_test.go` pins the old UUIDs. - **Plugin library gate.** Inline policies need a plugin built on agent ≥ `pluginlib.MinInlinePolicy` - (`internal/pluginlib`). Set it to the first release that ships `policy_id` seeding. + (`internal/pluginlib`, v0.8.0). Set it to the first release that ships `policy_id` seeding; versions compare as + semver, so pre-releases of the minimum (the v0.8.0 RCs, which predate it) are older and unsupported. - **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/cmd/compat_test.go b/cmd/compat_test.go index 724ec39..6a17d4a 100644 --- a/cmd/compat_test.go +++ b/cmd/compat_test.go @@ -98,7 +98,7 @@ func TestCompat_AssigningInlineBundleToOldLibIsRejected_R79(t *testing.T) { func TestCompat_SupportedLibApplies_R79(t *testing.T) { h, _ := newInlineHarness(t) - withPluginLib(h, "v0.9.0") + withPluginLib(h, "v0.8.0") h.remote.publish(1, inlineOverlay) active := mustStartup(t, h.rc) r := h.remote.lastReport(t) @@ -110,7 +110,7 @@ func TestCompat_SupportedLibApplies_R79(t *testing.T) { t.Fatalf("no %s expected for a supported plugin, got %+v", code, got) } } - if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "v0.9.0" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { + if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "v0.8.0" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { t.Fatalf("plugins report = %+v", r.Plugins) } } @@ -170,13 +170,14 @@ func TestLibProblemsNameAnUntaggedVersion(t *testing.T) { func TestInlineSupport(t *testing.T) { for version, want := range map[string]string{ - "v0.9.0": inlinePoliciesSupported, - "v0.9.0-rc1": inlinePoliciesSupported, - "v0.8.0": inlinePoliciesUnsupported, - "v0.7.1": inlinePoliciesUnsupported, - "": inlinePoliciesUnknown, - "(devel)": inlinePoliciesUnknown, - "v0.0.0-20261001110117-f88bde9ee37a": inlinePoliciesUnknown, + "v0.9.0": inlinePoliciesSupported, + "v0.8.0": inlinePoliciesSupported, + "v0.8.1-0.20261001000000-abcdefabcdef": inlinePoliciesSupported, + "v0.8.0-rc4": inlinePoliciesUnsupported, // predates R74 (R80) + "v0.7.1": inlinePoliciesUnsupported, + "": inlinePoliciesUnknown, + "(devel)": inlinePoliciesUnknown, + "v0.0.0-20261001110117-f88bde9ee37a": inlinePoliciesUnknown, } { if got := inlineSupport(version); got != want { t.Errorf("inlineSupport(%q) = %s, want %s", version, got, want) @@ -193,7 +194,7 @@ func TestCompat_OverlayMovingAnInlinePluginToAnOldBuildIsRejected_R79(t *testing if source == "ghcr.io/compliance-framework/plugin-ssh:v0" { return "v0.7.2", nil } - return "v0.9.0", nil + return "v0.8.0", nil } h.remote.publish(1, `{"plugins":{"ssh":{"source":"ghcr.io/compliance-framework/plugin-ssh:v0"}}}`) active := mustStartup(t, h.rc) diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index ef1329c..6c0028a 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -93,11 +93,11 @@ replaces by the seeds plugins would compute from the extends source's path and t What a plugin can do 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 gates inline policies on -`pluginlib.MinInlinePolicy` (v0.9.0: no release has R74 yet and v0.8.0 is being cut without it; update the constant -if that changes). We chose a hard gate over per-feature warnings because a plugin that ignores `policy_id` silently +`pluginlib.MinInlinePolicy` (v0.8.0 final, R80: its release candidates rc1 to rc4 predate R74, so versions compare +as plain semver and those pre-releases are older than the minimum; lower the constant if an RC with R74 is cut). We chose a hard gate over per-feature warnings because a plugin that ignores `policy_id` silently forks every overridden stream, and one older than v0.7.1 crashes on set-form violations. Only overlay-introduced inline policies are rejected; file bundles and unknown versions (a `replace` or devel build, which local development -relies on) warn. A pseudo-version counts as its base tag, since an untagged commit after v0.8.x may not contain R74. +relies on) warn. A pseudo-version counts as its base tag, since an untagged commit after v0.7.x or a v0.8.0 RC may not contain R74. ### Stable inline paths (R67) diff --git a/docs/configuration.md b/docs/configuration.md index ed40900..5c0df73 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -332,13 +332,16 @@ policy_id := "ssh-deny-password-auth" ``` - **Without `policy_id` nothing changes**: the seed is exactly what it has always been. -- **With one**, `policy-manager` seeds with `policyeval.SeedPath`: the `policy_file` seed is the `policy_id`, and the - `_policy_path` seed (when the plugin labels it) is the `policy_id` minus the module's bundle-relative path when it - ends in `/`, else the `policy_id` itself. Evidence keeps its real `_policy_path` label and gains `_policy_id`. -- **Continue a stream** with `policy_id := "/"`: the old location reproduces the old UUID, so an - override keeps writing to the vendor policy's stream. The UI pre-fills it from the report's `plugin-path`. - (A local source configured with a non-clean path such as `./policies` is the exception for plugins that label - `_policy_path`: their seed keeps the literal `./policies`, which a `policy_id` does not reproduce.) +- **With one**, `policy-manager` seeds with `policyeval.SeedPath`: the `policy_file` seed is the cleaned `policy_id` + (as OPA cleans the policy file), and the `_policy_path` seed (when the plugin labels it) is the literal `policy_id` + minus the module's bundle-relative path when it ends in `/`, else the `policy_id` itself. A `policy_id` that + names the module's own location seeds as if it had none. Evidence keeps its real `_policy_path` label and gains + `_policy_id`. +- **Continue a stream** with `policy_id := "/"`, the literal concatenation of the report's + `plugin-path`, a `/` and the file (not a cleaned join): the old location reproduces the old UUID, so an override + keeps writing to the vendor policy's stream. The UI pre-fills it from the report's `plugin-path`. Because the + `plugin-path` is kept as is, this also works for a local source configured with a non-clean path such as + `./policies` or `policies/` (`"./policies/"`, `"policies//"`). - **A stable stream**: any other `policy_id` (for example `/`) does not depend on where the bundle lives. - `policy_id` must be `policy_id := ""`, declared once per package, at most 512 characters @@ -354,7 +357,7 @@ policy_id := "ssh-deny-password-auth" | change the overridden module's `package` | a new stream (`policy-package-changed`) | **Plugins must be rebuilt.** Plugins seed evidence with the `policy-manager` they embed, so `policy_id` only takes -effect for plugins built on an agent library that includes it (agent ≥ v0.9.0, `pluginlib.MinInlinePolicy`). +effect for plugins built on an agent library that includes it (agent ≥ v0.8.0, `pluginlib.MinInlinePolicy`). ### Plugin compatibility (R76, R79) @@ -362,10 +365,12 @@ The agent reads each plugin's agent library version from the binary's Go build i reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: `supported`, `unsupported` or `unknown`). -- **Inline policies need agent ≥ v0.9.0** (the first release with `policy_id`; it also covers set-form violations). +- **Inline policies need agent ≥ v0.8.0** (the first release with `policy_id`; it also covers set-form violations). + The v0.8.0 release candidates (`v0.8.0-rc1` to `-rc4`) predate `policy_id` and count as older than v0.8.0, so + they are `unsupported`, as are pseudo-versions built after them. An overlay that gives an `inline:` entry to a plugin built on an older library, changes a bundle such a plugin uses, or moves a plugin that uses one to such a build (its `source`), is rejected before it is applied with `plugin-lib-inline-unsupported` ("plugin `

` (agent lib ``) doesn't - support inline policies; upgrade the plugin to a build on agent ≥ v0.9.0"); the running configuration keeps + support inline policies; upgrade the plugin to a build on agent ≥ v0.8.0"); the running configuration keeps running. - **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect `violation[{...}] if { ... }`. An authored module that uses them for such a plugin is also named @@ -374,8 +379,9 @@ reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: ` binary without build info. Local plugin builds therefore keep working. - **File-defined inline bundles only warn** (R34), and for them each authored `policy_id` the plugin would ignore is a `plugin-lib-policy-id-unsupported` warning ("this module starts a new evidence stream"). -- A pseudo-version counts as the tag it was built after, so a plugin built on an unreleased commit after v0.8.x is - `unsupported` until it moves to a v0.9.0 build (or a `replace`, which is `unknown`). +- A pseudo-version counts as the tag it was built after: a plugin built on an unreleased commit after v0.7.x or a + v0.8.0 release candidate is `unsupported` until it moves to a v0.8.0 build (or a `replace`, which is `unknown`); + one built on a commit after v0.8.0 is `supported`. ## Remote configuration diff --git a/go.mod b/go.mod index d31688f..6d05dc3 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.19.1-0.20261001145443-5449a31fd701 + github.com/compliance-framework/api v0.19.1-0.20261001151144-f50e4d8c603d 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 diff --git a/go.sum b/go.sum index c4db27d..0649aed 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.19.1-0.20261001145443-5449a31fd701 h1:mzUmk/jkuz1zGlCQSfsY94HhRJjPTIbMbo82CinZn9A= -github.com/compliance-framework/api v0.19.1-0.20261001145443-5449a31fd701/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.19.1-0.20261001151144-f50e4d8c603d h1:3l5ngMlRw82l3N35X4MTfTBPiJRZ4/0TheKudTMYorQ= +github.com/compliance-framework/api v0.19.1-0.20261001151144-f50e4d8c603d/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= diff --git a/internal/inlinepolicy/identity.go b/internal/inlinepolicy/identity.go index 732acd9..7b5e11d 100644 --- a/internal/inlinepolicy/identity.go +++ b/internal/inlinepolicy/identity.go @@ -2,7 +2,6 @@ package inlinepolicy import ( "fmt" - "path" "path/filepath" "strings" @@ -110,9 +109,13 @@ func SeedOf(id ModuleIdentity, pluginPath string) (string, string) { // ContinuityPolicyID is the policy_id that makes a module at rel in a bundle continue the // stream of the module at rel in the policy path pluginPath when that one has no policy_id: -// its legacy policy file, which plugins see cleaned (OPA joins the path and the file). +// the literal pluginPath + "/" + rel (R77), not a cleaned join. policyeval.SeedPath cleans it +// into the policy_file seed, as OPA cleans the file it gives plugins, and trims rel off the +// raw string for the _policy_path seed, which so keeps a non-clean pluginPath such as +// "./policies" or "policies/" as plugins label it. Compare streams with SeedOf, not by +// comparing ids. func ContinuityPolicyID(pluginPath, rel string) string { - return path.Join(pluginPath, rel) + return pluginPath + "/" + rel } // OverrideStreams warns about authored modules of an extends bundle that replace a vendor diff --git a/internal/inlinepolicy/identity_test.go b/internal/inlinepolicy/identity_test.go index a40c31a..623539d 100644 --- a/internal/inlinepolicy/identity_test.go +++ b/internal/inlinepolicy/identity_test.go @@ -2,6 +2,9 @@ package inlinepolicy import ( "context" + "fmt" + "os" + "path/filepath" "testing" "github.com/compliance-framework/api/pkg/agentconfig" @@ -83,3 +86,34 @@ func TestOverrideStreams_R75(t *testing.T) { require.NoError(t, err) assert.Empty(t, OverrideStreams(m), "a bundle without extends overrides nothing") } + +// TestOverrideStreams_NonCleanLocalSource_R77: for a local source configured with a +// non-clean path, the continuity policy_id is the literal "/", and the +// override continues the stream; the cleaned join does not (plugins that label _policy_path +// seed with the literal path). +func TestOverrideStreams_NonCleanLocalSource_R77(t *testing.T) { + for _, pluginPath := range []string{"./policies", "./policies/", "policies/"} { + t.Run(pluginPath, func(t *testing.T) { + t.Chdir(t.TempDir()) + require.NoError(t, os.MkdirAll("policies", 0o755)) + require.NoError(t, os.WriteFile(filepath.Join("policies", "banner.rego"), []byte(vendorBanner), 0o644)) + resolve := func(_ context.Context, source string) (string, error) { return pluginPath, nil } + want := ContinuityPolicyID(pluginPath, "banner.rego") + assert.Equal(t, pluginPath+"/banner.rego", want, "the literal concatenation") + + override := func(id string) []agentconfig.PolicyError { + t.Helper() + src := "package compliance_framework.banner\n\npolicy_id := \"" + id + "\"\n\ntitle := \"Banner\"\n" + m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("local"), Modules: map[string]string{"banner.rego": src}}, resolve) + require.NoError(t, err) + return OverrideStreams(m) + } + assert.Empty(t, override(want), "the literal continuity policy_id continues the stream") + + forked := override("policies/banner.rego") + require.Len(t, forked, 1, "the cleaned path seeds a different _policy_path") + assert.Equal(t, CodePolicyStreamForked, forked[0].Code) + assert.Contains(t, forked[0].Message, fmt.Sprintf("policy_id := %q", want), "the warning names the literal continuity policy_id") + }) + } +} diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go index a467d39..957985e 100644 --- a/internal/pluginlib/pluginlib.go +++ b/internal/pluginlib/pluginlib.go @@ -28,10 +28,10 @@ const ( MinViolationSet = "v0.7.1" // MinInlinePolicy is the first agent library release with policy_id seeding (R74), and // so the first whose plugins may use inline policy bundles (R79). It also covers - // MinViolationSet. No release has it yet: v0.8.0 is being cut from main without R74 - // (v0.8.0-rc4 is the latest tag), so it is the next minor version, v0.9.0. Its - // pre-releases (v0.9.0-rc1, ...) count. Update it if R74 ships in another release. - MinInlinePolicy = "v0.9.0" + // MinViolationSet. R74 ships in v0.8.0 (R80); the v0.8.0-rc1 to -rc4 tags predate it, + // so the minimum is the final release and those pre-releases are older (AtLeast). If a + // later release candidate of v0.8.0 contains R74, lower it to that tag. + MinInlinePolicy = "v0.8.0" ) // Version returns the version of AgentModule the plugin binary at path was built with, or "" @@ -59,7 +59,8 @@ func Version(path string) (string, error) { // version that can be compared ("" for unknown, "(devel)", a pseudo-version with no tag // before it): then ok is false too. A pseudo-version counts as the tagged version it was // built after (v0.7.2-0.2026…-abc is v0.7.1 plus unreleased commits, which may not include -// what min added). Pre-releases of min count as min. +// what min added). Versions compare as semver, so pre-releases of min are older than min +// (v0.8.0-rc4 < v0.8.0): a release candidate cut before a feature landed does not have it. func AtLeast(version, min string) (ok, known bool) { base := version if module.IsPseudoVersion(version) { @@ -71,12 +72,7 @@ func AtLeast(version, min string) (ok, known bool) { if !semver.IsValid(base) { return false, false } - // "-0" is the lowest pre-release of min, so min's release candidates count. - floor := min - if semver.Prerelease(min) == "" { - floor = semver.Canonical(min) + "-0" - } - return semver.Compare(base, floor) >= 0, true + return semver.Compare(base, min) >= 0, true } // Cache memoizes Version per binary. A binary is identified by its path, size and diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go index cda6c0b..0a70072 100644 --- a/internal/pluginlib/pluginlib_test.go +++ b/internal/pluginlib/pluginlib_test.go @@ -21,11 +21,15 @@ func TestAtLeast(t *testing.T) { {"v0.1.9-0.20250101000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.1.8 {"v0.7.2-0.20260601000000-abcdefabcdef", MinViolationSet, true, true}, // after v0.7.1 {"v0.7.1-0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.0, before v0.7.1 + {"v0.7.1-rc1", MinViolationSet, false, true}, // semver: before v0.7.1 + {"v0.8.0-rc1", MinInlinePolicy, false, true}, // the v0.8.0 release candidates predate R74 (R80) {"v0.8.0-rc4", MinInlinePolicy, false, true}, - {"v0.8.0", MinInlinePolicy, false, true}, - {"v0.8.1-0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.8.0: R74 not guaranteed + {"v0.8.0-rc4.0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.8.0-rc4 + {"v0.7.2", MinInlinePolicy, false, true}, + {"v0.8.0", MinInlinePolicy, true, true}, + {"v0.8.1-0.20261001000000-abcdefabcdef", MinInlinePolicy, true, true}, // after v0.8.0 + {"v0.8.1", MinInlinePolicy, true, true}, {"v0.9.0-rc1", MinInlinePolicy, true, true}, - {"v0.9.0-rc1.0.20261002000000-abcdefabcdef", MinInlinePolicy, true, true}, {"v0.9.0", MinInlinePolicy, true, true}, {"v1.0.0", MinInlinePolicy, true, true}, {"", MinInlinePolicy, false, false}, diff --git a/policy-manager/policy_id_test.go b/policy-manager/policy_id_test.go index 93aab33..f75d725 100644 --- a/policy-manager/policy_id_test.go +++ b/policy-manager/policy_id_test.go @@ -199,28 +199,39 @@ func writeModule(t *testing.T, dir, name, src string) { require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644)) } -// TestPolicyIDContinuesADotSlashLocalSource: a local source configured as "./policies" is -// passed to plugins literally, while OPA gives them the cleaned file -// ("policies/"). A policy_id of path.Join(, ) reproduces that -// policy_file seed, so plugins that seed only with policy_file continue the stream. -// -// Plugins that also label _policy_path seed with the literal "./policies", which -// policyeval.SeedPath cannot recover from a cleaned policy_id; see the round-3 notes. -func TestPolicyIDContinuesADotSlashLocalSource(t *testing.T) { - t.Chdir(t.TempDir()) - const vendor = "./policies" - override := filepath.Join(t.TempDir(), "inline", "b", "current", "bundle") - writeModule(t, vendor, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\ntitle := \"a\"\n") - writeModule(t, override, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\npolicy_id := \""+path.Join(vendor, "a.rego")+"\"\n\ntitle := \"a\"\n") - - labels := map[string]string{"type": "local", "hostname": "web-1"} - generate := func(policyPath string) *proto.Evidence { - t.Helper() - processor := NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil) - evidence, err := processor.GenerateResults(context.Background(), policyPath, map[string]any{}) - require.NoError(t, err) - require.Len(t, evidence, 1) - return evidence[0] +// TestPolicyIDContinuesANonCleanLocalSource runs real bundles the way a plugin that labels +// _policy_path does: a local source configured with a non-clean path ("./policies") is +// passed to plugins literally, while OPA gives them the cleaned file ("policies/"). +// An override in an inline bundle whose policy_id is the literal "/" +// (R77) reproduces both seeds (policyeval.SeedPath), so it continues the vendor stream. +func TestPolicyIDContinuesANonCleanLocalSource(t *testing.T) { + for _, vendor := range []string{"./policies", "./policies/", "policies/", "policies"} { + t.Run(vendor, func(t *testing.T) { + t.Chdir(t.TempDir()) + override := filepath.Join(t.TempDir(), "inline", "b", "current", "bundle") + policyID := vendor + "/a.rego" + writeModule(t, "policies", "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\ntitle := \"a\"\n") + writeModule(t, override, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\npolicy_id := \""+policyID+"\"\n\ntitle := \"a\"\n") + + generate := func(policyPath string) *proto.Evidence { + t.Helper() + processor := NewPolicyProcessor(hclog.NewNullLogger(), sshLabels(policyPath), nil, nil, nil, nil, nil, nil) + evidence, err := processor.GenerateResults(context.Background(), policyPath, map[string]any{}) + require.NoError(t, err) + require.Len(t, evidence, 1) + return evidence[0] + } + legacy, overridden := generate(vendor), generate(override) + assert.Equal(t, vendor, legacy.Labels["_policy_path"]) + assert.Equal(t, legacy.UUID, overridden.UUID, "the override continues the vendor stream") + assert.Equal(t, policyID, overridden.Labels[labelPolicyID]) + assert.Equal(t, override, overridden.Labels["_policy_path"]) + + // The cleaned join reproduces the policy file but not the literal _policy_path. + if cleaned := path.Join(vendor, "a.rego"); cleaned != policyID { + writeModule(t, override, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\npolicy_id := \""+cleaned+"\"\n\ntitle := \"a\"\n") + assert.NotEqual(t, legacy.UUID, generate(override).UUID) + } + }) } - assert.Equal(t, generate(vendor).UUID, generate(override).UUID) } From 06d9ad64a7dd67ac1c3f6b322cd0a85334965751 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:43:22 -0300 Subject: [PATCH 29/47] feat: report extends.plugin-path for R78 continuity --- cmd/inline_test.go | 199 +++++++++++++++++++++++++++ docs/configuration.md | 3 +- go.mod | 2 +- go.sum | 4 +- internal/inlinepolicy/materialize.go | 4 + 5 files changed, 208 insertions(+), 4 deletions(-) diff --git a/cmd/inline_test.go b/cmd/inline_test.go index 9b661ad..446b7e3 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -10,7 +10,10 @@ import ( "strings" "testing" + "github.com/compliance-framework/agent/internal/inlinepolicy" + policy_manager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/hashicorp/go-hclog" ) const inlineBaseConfig = ` @@ -276,6 +279,202 @@ func TestInline_ReportsPluginPaths_R77(t *testing.T) { } } +// r78Vendor is the vendor module of the R78 tests: a titled policy, so plugins record +// evidence for it. +const r78Vendor = "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation contains {\"remarks\": \"b\"} if not input.banner\n" + +// r78Harness is an inline harness whose bundle extends source, resolved by resolve; with +// direct, a second plugin also loads source directly. +func r78Harness(t *testing.T, source string, direct bool, resolve func(context.Context, string) (string, error)) *remoteHarness { + t.Helper() + config := strings.Replace(inlineBaseConfig, "extends: ghcr.io/vendor/policies:v1", "extends: "+source, 1) + if direct { + config = strings.Replace(config, `policies: ["inline:ssh"]`, `policies: ["inline:ssh"] + other: + source: ghcr.io/compliance-framework/plugin-other:v1 + policies: ["`+source+`"]`, 1) + } + h := newRemoteHarness(t, config) + h.rc.resolvePolicy = resolve + return h +} + +// r78Report returns the inline bundle's report and the plugin-path of each source that has +// its own policy-bundles entry. +func r78Report(t *testing.T, h *remoteHarness) (agentconfig.PolicyBundleReport, map[string]string) { + t.Helper() + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied { + t.Fatalf("expected applied, got %s/%s %v", r.Status, r.Reason, r.PolicyErrors) + } + var inline agentconfig.PolicyBundleReport + direct := map[string]string{} + for _, b := range r.PolicyBundles { + if b.Source == "inline:ssh" { + inline = b + } else { + direct[b.Source] = b.PluginPath + } + } + if inline.Extends == nil { + t.Fatalf("no extends report: %+v", r.PolicyBundles) + } + return inline, direct +} + +// r78Evidence evaluates the policies at policyPath the way a plugin that labels _policy_path +// does and returns the UUID of the banner policy's evidence. +func r78Evidence(t *testing.T, policyPath string) string { + t.Helper() + labels := map[string]string{"type": "ssh", "hostname": "web-1", "_policy_path": policyPath} + evidence, err := policy_manager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil).GenerateResults(context.Background(), policyPath, map[string]any{}) + if err != nil { + t.Fatal(err) + } + for _, e := range evidence { + if e.Labels["_policy"] == "compliance_framework.banner" { + return e.UUID + } + } + t.Fatalf("no banner evidence at %s", policyPath) + return "" +} + +// TestInline_ExtendsPluginPath_R78: an inline bundle's extends report carries the literal path +// plugins would get for the extends source, the same as the source's own plugin-path when a +// plugin loads it directly, and still when none does (the bundle replaced the source, R66). A +// module whose policy_id is extends.plugin-path + "/" + file continues the vendor's stream. +func TestInline_ExtendsPluginPath_R78(t *testing.T) { + const source = "ghcr.io/vendor/policies:v1" + // Where an OCI source is extracted to: relative to the working directory, ending in the + // artifact's policies directory. + const extracted = ".compliance-framework/policies/vendor/policies/v1/policies" + t.Chdir(t.TempDir()) + if err := os.MkdirAll(extracted, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(extracted, "banner.rego"), []byte(r78Vendor), 0o644); err != nil { + t.Fatal(err) + } + resolve := func(_ context.Context, s string) (string, error) { + if s == source { + return extracted, nil + } + return "", errors.New("unknown source " + s) + } + + loaded := r78Harness(t, source, true, resolve) + mustStartup(t, loaded.rc) + inline, direct := r78Report(t, loaded) + if inline.Extends.PluginPath != extracted || direct[source] != extracted { + t.Fatalf("extends plugin-path = %q, source plugin-path = %q, want both %q", inline.Extends.PluginPath, direct[source], extracted) + } + + h := r78Harness(t, source, false, resolve) + h.remote.publish(0, `{}`) + mustStartup(t, h.rc) + inline, direct = r78Report(t, h) + if _, ok := direct[source]; ok { + t.Fatalf("no plugin loads %s directly, yet it is reported: %+v", source, direct) + } + if inline.Extends.PluginPath != extracted { + t.Fatalf("extends plugin-path = %q, want %q", inline.Extends.PluginPath, extracted) + } + + // Override the vendor module with the continuity policy_id built from the report. + policyID := inline.Extends.PluginPath + "/banner.rego" + override := strings.Replace(r78Vendor, "title :=", fmt.Sprintf("policy_id := %q\n\ntitle :=", policyID), 1) + overlay, err := json.Marshal(map[string]any{"policy_bundles": map[string]any{"ssh": map[string]any{"modules": map[string]string{"banner.rego": override}}}}) + if err != nil { + t.Fatal(err) + } + h.remote.publish(1, string(overlay)) + before := h.remote.reportCount() + active := h.poll(t) + if h.remote.reportCount() == before { + t.Fatal("the override revision was not reported") + } + r78Report(t, h) + if forked := policyErrorsWithCode(h.remote.lastReport(t), inlinepolicy.CodePolicyStreamForked); len(forked) != 0 { + t.Fatalf("the continuity policy_id must not fork the stream: %+v", forked) + } + // The run loop points the stable path at the new tree before the next run (R67). + if err := h.rc.activateInline(active); err != nil { + t.Fatal(err) + } + if got, want := r78Evidence(t, active.runtime.inlinePolicyDirs["inline:ssh"]), r78Evidence(t, extracted); got != want { + t.Fatalf("override evidence UUID = %s, want the vendor's %s", got, want) + } +} + +// TestInline_ExtendsPluginPathKeepsALocalSourceLiteral_R78: a local extends source configured +// with a non-clean path is reported as configured, as plugins get it from the real resolver. +func TestInline_ExtendsPluginPathKeepsALocalSourceLiteral_R78(t *testing.T) { + for _, source := range []string{"./policies", "./policies/", "policies/"} { + t.Run(source, func(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.MkdirAll("policies", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("policies", "banner.rego"), []byte(r78Vendor), 0o644); err != nil { + t.Fatal(err) + } + ar := NewAgentRunner() + resolve := func(ctx context.Context, s string) (string, error) { return ar.downloadPolicy(ctx, s, nil) } + + loaded := r78Harness(t, source, true, resolve) + mustStartup(t, loaded.rc) + inline, direct := r78Report(t, loaded) + if inline.Extends.PluginPath != source || direct[source] != source { + t.Fatalf("extends plugin-path = %q, source plugin-path = %q, want both the literal %q", inline.Extends.PluginPath, direct[source], source) + } + + h := r78Harness(t, source, false, resolve) + mustStartup(t, h.rc) + if inline, _ := r78Report(t, h); inline.Extends.PluginPath != source { + t.Fatalf("extends plugin-path = %q, want the literal %q", inline.Extends.PluginPath, source) + } + }) + } +} + +// TestInline_ExtendsPluginPathSurvivesTruncation_R78: shrinking the report keeps both +// plugin-paths whole, as it keeps artifact-digest. The agent never cuts a path: a cut path +// would be a wrong one, so the API drops an oversized one whole (for both fields alike). +func TestInline_ExtendsPluginPathSurvivesTruncation_R78(t *testing.T) { + h, vendor := newInlineHarness(t) + mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if got := r.PolicyBundles[0].Extends.PluginPath; got != vendor { + t.Fatalf("extends plugin-path = %q, want %q", got, vendor) + } + ext := *r.PolicyBundles[0].Extends + r.PolicyBundles = append([]agentconfig.PolicyBundleReport(nil), r.PolicyBundles...) + r.PolicyBundles[0].Extends = &ext + long := strings.Repeat("p/", 2100) + "policies" + r.PolicyBundles[0].Extends.PluginPath = long + plugin := r.PolicyBundles[0].PluginPath + + body, _, err := fitReport(&r, true) + if err != nil { + t.Fatal(err) + } + b := r.PolicyBundles[0] + if !r.Truncated || len(b.Files) != 0 || len(b.Extends.Files) != 0 { + t.Fatalf("the forced fit must drop the file lists: %+v", b) + } + if b.PluginPath != plugin || b.Extends.PluginPath != long { + t.Fatalf("truncation must keep both plugin-paths whole: %q, %q", b.PluginPath, b.Extends.PluginPath) + } + var sent agentconfig.Report + if err := json.Unmarshal(body, &sent); err != nil { + t.Fatal(err) + } + if sent.PolicyBundles[0].Extends.PluginPath != long { + t.Fatal("the sent report must carry extends.plugin-path whole") + } +} + // TestInline_StablePathSwapsOnlyBetweenRuns_R67: a revision prepared while a run is in // progress does not move the stable path under it; the run loop points it at the new tree // before the next run, and back at the previous tree when it falls back. diff --git a/docs/configuration.md b/docs/configuration.md index 5c0df73..111806e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -307,7 +307,8 @@ policy_bundles: - **Plugin paths (R77).** Each `policy-bundles[]` entry of the configuration report carries `plugin-path`, the exact path string the agent passes plugins for that source: the stable path for an inline bundle, and the path the agent extracted an OCI source to, or a local source as configured. It is what a continuity `policy_id` is built - from. + from. An inline bundle's `extends` also carries `plugin-path` (R78), the same path the source would get if a plugin + loaded it directly, so continuity ids use `extends.plugin-path` once the bundle has replaced the source everywhere. - **Sources for the UI (R62).** Outside mode `off`, the agent uploads every policy tree it reports (each inline bundle, the tree it extends, and each OCI or local source a plugin uses) as a policy bundle artifact, and reports its `artifact-digest` next to the tree digest, so the UI can show and pre-fill vendor sources. These are the same diff --git a/go.mod b/go.mod index 6d05dc3..17b7420 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.19.1-0.20261001151144-f50e4d8c603d + github.com/compliance-framework/api v0.20.1-0.20261001154205-5baeb7812db2 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 diff --git a/go.sum b/go.sum index 0649aed..4df83ba 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.19.1-0.20261001151144-f50e4d8c603d h1:3l5ngMlRw82l3N35X4MTfTBPiJRZ4/0TheKudTMYorQ= -github.com/compliance-framework/api v0.19.1-0.20261001151144-f50e4d8c603d/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.20.1-0.20261001154205-5baeb7812db2 h1:U2M0NvN6SK4WVoc80rWxgjWatQIRbvqJMWLulHxuefs= +github.com/compliance-framework/api v0.20.1-0.20261001154205-5baeb7812db2/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= diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index cfa5597..25bf942 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -135,6 +135,10 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu Source: *b.Extends, Digest: agentconfig.BundleTreeDigest(baseFiles), Files: inventory(baseFiles), + // The resolver output is the literal path plugins get for the source (R77), so a + // client can build continuity ids from it even when no plugin loads the source + // directly any more (R78). + PluginPath: dir, } m.ExtendsDir = dir m.ExtendsIdentities = Identities(baseFiles) From 2aac869652a3002a687313ffceb561cb016544a4 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:57:24 -0300 Subject: [PATCH 30/47] fix: gate inline policies on agent v0.9.0 (v0.8.0/v0.8.1 shipped without R74; R81) --- AGENTS.md | 5 +++-- cmd/compat_test.go | 15 +++++++++------ docs/adr/0003-remote-config-overlay.md | 7 ++++--- docs/configuration.md | 16 ++++++++-------- internal/pluginlib/pluginlib.go | 12 +++++++----- internal/pluginlib/pluginlib_test.go | 14 +++++++++----- 6 files changed, 40 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f95383a..c9925dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,8 +118,9 @@ change here must keep working with them. field compute it. Never change the seed of a policy without `policy_id`; `policy_id` changes it only through the API's `policyeval.SeedPath`. The golden test in `policy-manager/policy_id_test.go` pins the old UUIDs. - **Plugin library gate.** Inline policies need a plugin built on agent ≥ `pluginlib.MinInlinePolicy` - (`internal/pluginlib`, v0.8.0). Set it to the first release that ships `policy_id` seeding; versions compare as - semver, so pre-releases of the minimum (the v0.8.0 RCs, which predate it) are older and unsupported. + (`internal/pluginlib`, v0.9.0, R81). Set it to the first release that ships `policy_id` seeding (v0.8.0 and + v0.8.1 shipped without it); update it before tagging if agent#95 ships in a different release. Versions compare as + semver, so pre-releases of the minimum (`v0.9.0-rc*`) are older and unsupported. - **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/cmd/compat_test.go b/cmd/compat_test.go index 6a17d4a..d7e8bd0 100644 --- a/cmd/compat_test.go +++ b/cmd/compat_test.go @@ -98,7 +98,7 @@ func TestCompat_AssigningInlineBundleToOldLibIsRejected_R79(t *testing.T) { func TestCompat_SupportedLibApplies_R79(t *testing.T) { h, _ := newInlineHarness(t) - withPluginLib(h, "v0.8.0") + withPluginLib(h, "v0.9.0") h.remote.publish(1, inlineOverlay) active := mustStartup(t, h.rc) r := h.remote.lastReport(t) @@ -110,7 +110,7 @@ func TestCompat_SupportedLibApplies_R79(t *testing.T) { t.Fatalf("no %s expected for a supported plugin, got %+v", code, got) } } - if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "v0.8.0" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { + if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "v0.9.0" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { t.Fatalf("plugins report = %+v", r.Plugins) } } @@ -171,9 +171,12 @@ func TestLibProblemsNameAnUntaggedVersion(t *testing.T) { func TestInlineSupport(t *testing.T) { for version, want := range map[string]string{ "v0.9.0": inlinePoliciesSupported, - "v0.8.0": inlinePoliciesSupported, - "v0.8.1-0.20261001000000-abcdefabcdef": inlinePoliciesSupported, - "v0.8.0-rc4": inlinePoliciesUnsupported, // predates R74 (R80) + "v0.10.0": inlinePoliciesSupported, + "v0.9.1-0.20261001000000-abcdefabcdef": inlinePoliciesSupported, + "v0.9.0-rc1": inlinePoliciesUnsupported, // semver: before v0.9.0 + "v0.8.1": inlinePoliciesUnsupported, // released without R74 (R81) + "v0.8.0": inlinePoliciesUnsupported, // released without R74 (R81) + "v0.8.0-rc4": inlinePoliciesUnsupported, "v0.7.1": inlinePoliciesUnsupported, "": inlinePoliciesUnknown, "(devel)": inlinePoliciesUnknown, @@ -194,7 +197,7 @@ func TestCompat_OverlayMovingAnInlinePluginToAnOldBuildIsRejected_R79(t *testing if source == "ghcr.io/compliance-framework/plugin-ssh:v0" { return "v0.7.2", nil } - return "v0.8.0", nil + return "v0.9.0", nil } h.remote.publish(1, `{"plugins":{"ssh":{"source":"ghcr.io/compliance-framework/plugin-ssh:v0"}}}`) active := mustStartup(t, h.rc) diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 6c0028a..41c86ff 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -93,11 +93,12 @@ replaces by the seeds plugins would compute from the extends source's path and t What a plugin can do 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 gates inline policies on -`pluginlib.MinInlinePolicy` (v0.8.0 final, R80: its release candidates rc1 to rc4 predate R74, so versions compare -as plain semver and those pre-releases are older than the minimum; lower the constant if an RC with R74 is cut). We chose a hard gate over per-feature warnings because a plugin that ignores `policy_id` silently +`pluginlib.MinInlinePolicy` (v0.9.0 final, R81, superseding R80: v0.8.0 and v0.8.1 were released without R74; +versions compare as plain semver, so v0.9.0 release candidates are older than the minimum; update the constant before +tagging if agent#95 ships in a different release). We chose a hard gate over per-feature warnings because a plugin that ignores `policy_id` silently forks every overridden stream, and one older than v0.7.1 crashes on set-form violations. Only overlay-introduced inline policies are rejected; file bundles and unknown versions (a `replace` or devel build, which local development -relies on) warn. A pseudo-version counts as its base tag, since an untagged commit after v0.7.x or a v0.8.0 RC may not contain R74. +relies on) warn. A pseudo-version counts as its base tag, since an untagged commit after v0.8.x or a v0.9.0 RC may not contain R74. ### Stable inline paths (R67) diff --git a/docs/configuration.md b/docs/configuration.md index 111806e..297c66a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -358,7 +358,7 @@ policy_id := "ssh-deny-password-auth" | change the overridden module's `package` | a new stream (`policy-package-changed`) | **Plugins must be rebuilt.** Plugins seed evidence with the `policy-manager` they embed, so `policy_id` only takes -effect for plugins built on an agent library that includes it (agent ≥ v0.8.0, `pluginlib.MinInlinePolicy`). +effect for plugins built on an agent library that includes it (agent ≥ v0.9.0, `pluginlib.MinInlinePolicy`). ### Plugin compatibility (R76, R79) @@ -366,12 +366,12 @@ The agent reads each plugin's agent library version from the binary's Go build i reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: `supported`, `unsupported` or `unknown`). -- **Inline policies need agent ≥ v0.8.0** (the first release with `policy_id`; it also covers set-form violations). - The v0.8.0 release candidates (`v0.8.0-rc1` to `-rc4`) predate `policy_id` and count as older than v0.8.0, so - they are `unsupported`, as are pseudo-versions built after them. +- **Inline policies need agent ≥ v0.9.0** (the first release with `policy_id`; it also covers set-form violations). + v0.8.0 and v0.8.1 were released without `policy_id`, and v0.9.0 release candidates (`v0.9.0-rc*`) count as older + than v0.9.0, so they are `unsupported`, as are pseudo-versions built after them. An overlay that gives an `inline:` entry to a plugin built on an older library, changes a bundle such a plugin uses, or moves a plugin that uses one to such a build (its `source`), is rejected before it is applied with `plugin-lib-inline-unsupported` ("plugin `

` (agent lib ``) doesn't - support inline policies; upgrade the plugin to a build on agent ≥ v0.8.0"); the running configuration keeps + support inline policies; upgrade the plugin to a build on agent ≥ v0.9.0"); the running configuration keeps running. - **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect `violation[{...}] if { ... }`. An authored module that uses them for such a plugin is also named @@ -380,9 +380,9 @@ reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: ` binary without build info. Local plugin builds therefore keep working. - **File-defined inline bundles only warn** (R34), and for them each authored `policy_id` the plugin would ignore is a `plugin-lib-policy-id-unsupported` warning ("this module starts a new evidence stream"). -- A pseudo-version counts as the tag it was built after: a plugin built on an unreleased commit after v0.7.x or a - v0.8.0 release candidate is `unsupported` until it moves to a v0.8.0 build (or a `replace`, which is `unknown`); - one built on a commit after v0.8.0 is `supported`. +- A pseudo-version counts as the tag it was built after: a plugin built on an unreleased commit after v0.8.x or a + v0.9.0 release candidate is `unsupported` until it moves to a v0.9.0 build (or a `replace`, which is `unknown`); + one built on a commit after v0.9.0 is `supported`. ## Remote configuration diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go index 957985e..83890d6 100644 --- a/internal/pluginlib/pluginlib.go +++ b/internal/pluginlib/pluginlib.go @@ -28,10 +28,12 @@ const ( MinViolationSet = "v0.7.1" // MinInlinePolicy is the first agent library release with policy_id seeding (R74), and // so the first whose plugins may use inline policy bundles (R79). It also covers - // MinViolationSet. R74 ships in v0.8.0 (R80); the v0.8.0-rc1 to -rc4 tags predate it, - // so the minimum is the final release and those pre-releases are older (AtLeast). If a - // later release candidate of v0.8.0 contains R74, lower it to that tag. - MinInlinePolicy = "v0.8.0" + // MinViolationSet. R74 ships in v0.9.0 (R81, superseding R80): v0.8.0 and v0.8.1 were + // released without it. The minimum is the final release, so v0.9.0 release candidates + // and pseudo-versions based on them are older (AtLeast). + // + // Update before tagging if agent#95 ships in a different release. + MinInlinePolicy = "v0.9.0" ) // Version returns the version of AgentModule the plugin binary at path was built with, or "" @@ -60,7 +62,7 @@ func Version(path string) (string, error) { // before it): then ok is false too. A pseudo-version counts as the tagged version it was // built after (v0.7.2-0.2026…-abc is v0.7.1 plus unreleased commits, which may not include // what min added). Versions compare as semver, so pre-releases of min are older than min -// (v0.8.0-rc4 < v0.8.0): a release candidate cut before a feature landed does not have it. +// (v0.9.0-rc1 < v0.9.0): a release candidate cut before a feature landed does not have it. func AtLeast(version, min string) (ok, known bool) { base := version if module.IsPseudoVersion(version) { diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go index 0a70072..7623751 100644 --- a/internal/pluginlib/pluginlib_test.go +++ b/internal/pluginlib/pluginlib_test.go @@ -22,15 +22,19 @@ func TestAtLeast(t *testing.T) { {"v0.7.2-0.20260601000000-abcdefabcdef", MinViolationSet, true, true}, // after v0.7.1 {"v0.7.1-0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.0, before v0.7.1 {"v0.7.1-rc1", MinViolationSet, false, true}, // semver: before v0.7.1 - {"v0.8.0-rc1", MinInlinePolicy, false, true}, // the v0.8.0 release candidates predate R74 (R80) + {"v0.8.0-rc1", MinInlinePolicy, false, true}, // predate R74 {"v0.8.0-rc4", MinInlinePolicy, false, true}, {"v0.8.0-rc4.0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.8.0-rc4 {"v0.7.2", MinInlinePolicy, false, true}, - {"v0.8.0", MinInlinePolicy, true, true}, - {"v0.8.1-0.20261001000000-abcdefabcdef", MinInlinePolicy, true, true}, // after v0.8.0 - {"v0.8.1", MinInlinePolicy, true, true}, - {"v0.9.0-rc1", MinInlinePolicy, true, true}, + {"v0.8.0", MinInlinePolicy, false, true}, // released without R74 (R81) + {"v0.8.1", MinInlinePolicy, false, true}, // released without R74 (R81) + {"v0.8.2-0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.8.1 + {"v0.9.0-rc1", MinInlinePolicy, false, true}, // semver: before v0.9.0 + {"v0.9.0-rc1.0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.9.0-rc1 {"v0.9.0", MinInlinePolicy, true, true}, + {"v0.9.1-0.20261001000000-abcdefabcdef", MinInlinePolicy, true, true}, // after v0.9.0 + {"v0.9.1", MinInlinePolicy, true, true}, + {"v0.10.0", MinInlinePolicy, true, true}, {"v1.0.0", MinInlinePolicy, true, true}, {"", MinInlinePolicy, false, false}, {"(devel)", MinInlinePolicy, false, false}, From b6e2f663801af6e43f9ad55300199cb4fa0ddb0f Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 1 Oct 2026 14:36:19 -0300 Subject: [PATCH 31/47] wip(R82): continue vendor evidence streams; local-source-style inline path (a) While materializing an inline bundle that extends a source, append `policy_id := "/"` (or the vendor package's own policy_id) to every non-test compliance_framework module that continues a vendor file (inherited unchanged, or overridden at the same path with the same package) and whose package declares no policy_id. Explicit policy_id always wins; a package with more than one non-test module in the bundle is skipped with a policy-id-continuity-skipped warning. Modules are parsed as Rego v1, then v0. Identities (and so R75 OverrideStreams / duplicate checks) are computed on the tree as written, so modules that now continue are no longer reported as policy-stream-forked. The report's files[] describe the tree as written (matching its digest and artifact); extends.files[] keep the vendor hashes, which is what the UI derives inherited/overridden from. (b) Plugins receive inline bundles at the relative .compliance-framework/policies/inline//policies, a symlink (temp + rename) to the content-addressed tree under the state dir, which now holds a real policies/ directory. GC removes trees and current links of the R67 layout; Materialize rebuilds an R67 tree when needed. Co-Authored-By: Claude Opus 5.5 --- cmd/agent.go | 3 +- cmd/inline.go | 24 +- cmd/inline_test.go | 58 +++- cmd/reconciler.go | 3 + cmd/remote_test.go | 1 + docs/adr/0003-remote-config-overlay.md | 5 +- docs/configuration.md | 36 ++- docs/policy_artifacts.md | 2 +- internal/inlinepolicy/activate_test.go | 50 ++-- internal/inlinepolicy/continuity_test.go | 294 +++++++++++++++++++++ internal/inlinepolicy/identity.go | 130 ++++++++- internal/inlinepolicy/identity_test.go | 13 +- internal/inlinepolicy/inlinepolicy_test.go | 64 +++-- internal/inlinepolicy/materialize.go | 205 +++++++++----- 14 files changed, 737 insertions(+), 151 deletions(-) create mode 100644 internal/inlinepolicy/continuity_test.go diff --git a/cmd/agent.go b/cmd/agent.go index 0088203..5b53ed7 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -94,7 +94,8 @@ type agentConfig struct { AgentEvidence *agentEvidenceConfig `mapstructure:"agent_evidence"` // inlinePolicyDirs maps "inline:" policy entries to the path plugins receive: the - // bundle's stable path, which the reconciler points at inlineTrees before each run (R67). + // bundle's stable path (.compliance-framework/policies/inline//policies, R82), which + // the reconciler points at inlineTrees before each run (R67). inlinePolicyDirs map[string]string // inlineTrees maps "inline:" policy entries to their materialized, content-addressed // tree (inlinepolicy.Materialized.Dir). diff --git a/cmd/inline.go b/cmd/inline.go index 8b9362f..ec95dbc 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -16,9 +16,19 @@ import ( // active ones. const inlineGCKeepPerBundle = 5 -// inlineRoot is where inline bundles are materialized (under the state dir, R31). -func (rc *reconciler) inlineRoot() string { - return filepath.Join(rc.store.Dir(), "inline") +// inlineLinksDir is where the stable links of inline bundles live, relative to the agent's +// working directory like the OCI policy cache next to it, so plugins receive an inline +// bundle as .compliance-framework/policies/inline//policies (R82). +var inlineLinksDir = filepath.Join(AgentPolicyDir, "inline") + +// inlineLayout is where inline bundles are materialized (under the state dir, R31) and +// where plugins receive them (R82). +func (rc *reconciler) inlineLayout() inlinepolicy.Layout { + links := rc.inlineLinks + if links == "" { + links = inlineLinksDir + } + return inlinepolicy.Layout{Store: filepath.Join(rc.store.Dir(), "inline"), Links: links} } // prepareInline is prepare step 8 (G3b): the parse-level checks on every bundle, then @@ -60,7 +70,7 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co materialized := map[string]*inlinepolicy.Materialized{} var problems []agentconfig.PolicyError for _, name := range sortedBoolKeys(refs) { - m, err := inlinepolicy.Materialize(ctx, rc.inlineRoot(), name, resolved.PolicyBundles[name], rc.boundedResolver()) + m, err := inlinepolicy.Materialize(ctx, rc.inlineLayout(), name, resolved.PolicyBundles[name], rc.boundedResolver()) var perrs inlinepolicy.PolicyErrors switch { case errors.As(err, &perrs): @@ -248,7 +258,7 @@ func (rc *reconciler) afterStartup(active *candidate) { } // gcInline removes materialized inline bundles that none of keep, the running, pending, -// starting or fallback candidate, nor a bundle's current symlink uses, and that are not among +// starting or fallback candidate, nor a bundle's stable link uses, and that are not among // the newest inlineGCKeepPerBundle per bundle. It runs after startup and after every swap, // so a long-running daemon does not accumulate one directory per revision. It holds // inlineMu, so it never races activateInline. @@ -276,7 +286,7 @@ func (rc *reconciler) gcInline(keep ...*candidate) { if !found { return } - if err := inlinepolicy.GC(rc.inlineRoot(), dirs, inlineGCKeepPerBundle); err != nil { + if err := inlinepolicy.GC(rc.inlineLayout(), dirs, inlineGCKeepPerBundle); err != nil { rc.logger.Warn("Could not clean up old inline policy bundles", "error", err) } } @@ -291,7 +301,7 @@ func (rc *reconciler) activateInline(c *candidate) error { var errs []error for _, entry := range sortedStringKeys(c.runtime.inlineTrees) { name := strings.TrimPrefix(entry, agentconfig.InlineSourcePrefix) - errs = append(errs, inlinepolicy.Activate(rc.inlineRoot(), name, c.runtime.inlineTrees[entry])) + errs = append(errs, inlinepolicy.Activate(rc.inlineLayout(), name, c.runtime.inlineTrees[entry])) } return errors.Join(errs...) } diff --git a/cmd/inline_test.go b/cmd/inline_test.go index 446b7e3..1bb1845 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -72,8 +72,11 @@ func TestInline_FileBundleMaterializedAndReported(t *testing.T) { h, _ := newInlineHarness(t) active := mustStartup(t, h.rc) dir, ok := active.runtime.inlinePolicyDirs["inline:ssh"] - if !ok || !strings.HasPrefix(dir, filepath.Join(h.dir, "state", "inline", "ssh")) { - t.Fatalf("inline bundle not materialized under the state dir: %v", active.runtime.inlinePolicyDirs) + if !ok || dir != filepath.Join(h.dir, "policies", "inline", "ssh", "policies") { + t.Fatalf("plugins must receive the bundle's stable link: %v", active.runtime.inlinePolicyDirs) + } + if tree := active.runtime.inlineTrees["inline:ssh"]; !strings.HasPrefix(tree, filepath.Join(h.dir, "state", "inline", "ssh")) { + t.Fatalf("inline bundle not materialized under the state dir: %s", tree) } for _, f := range []string{"banner.rego", "extra.rego"} { if _, err := os.Stat(filepath.Join(dir, f)); err != nil { @@ -151,7 +154,7 @@ func TestInline_GCAfterSwap(t *testing.T) { t.Fatalf("revision %d did not apply: %s/%s %s", rev, r.Status, r.Reason, derefString(r.Error)) } } - entries, err := os.ReadDir(filepath.Join(h.rc.inlineRoot(), "ssh")) + entries, err := os.ReadDir(filepath.Join(h.rc.inlineLayout().Store, "ssh")) if err != nil { t.Fatal(err) } @@ -271,7 +274,7 @@ func TestInline_ReportsPluginPaths_R77(t *testing.T) { for _, b := range r.PolicyBundles { got[b.Source] = b.PluginPath } - if got["inline:ssh"] != active.runtime.inlinePolicyDirs["inline:ssh"] || !strings.HasSuffix(got["inline:ssh"], filepath.Join("inline", "ssh", "current", "bundle")) { + if got["inline:ssh"] != active.runtime.inlinePolicyDirs["inline:ssh"] || !strings.HasSuffix(got["inline:ssh"], filepath.Join("inline", "ssh", "policies")) { t.Fatalf("inline plugin-path = %q, want the stable path %q", got["inline:ssh"], active.runtime.inlinePolicyDirs["inline:ssh"]) } if got["ghcr.io/vendor/policies:v1"] != vendor { @@ -549,7 +552,7 @@ func TestInline_EvidenceSourceIsTheBundle(t *testing.T) { mustStartup(t, h.rc) cfg := h.rc.running().runtime stable := cfg.inlinePolicyDirs["inline:ssh"] - if !strings.HasSuffix(filepath.ToSlash(stable), "/inline/ssh/current/bundle") { + if !strings.HasSuffix(filepath.ToSlash(stable), "/inline/ssh/policies") { t.Fatalf("plugins must receive the stable path, got %s", stable) } got := cfg.policySource("inline:ssh", stable) @@ -590,3 +593,48 @@ func TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75(t *testing.T) { } } } + +// TestInline_RelativePathContinuesVendorStreams_R82: plugins receive an inline bundle at +// the relative .compliance-framework/policies/inline//policies, like a local source, +// and an inherited vendor module keeps the vendor's evidence stream with no policy_id +// written by the user, next to an added module. +func TestInline_RelativePathContinuesVendorStreams_R82(t *testing.T) { + const source = "ghcr.io/vendor/policies:v1" + const extracted = ".compliance-framework/policies/vendor/policies/v1/policies" + t.Chdir(t.TempDir()) + if err := os.MkdirAll(extracted, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(extracted, "banner.rego"), []byte(r78Vendor), 0o644); err != nil { + t.Fatal(err) + } + h := r78Harness(t, source, false, func(_ context.Context, s string) (string, error) { + if s == source { + return extracted, nil + } + return "", errors.New("unknown source " + s) + }) + h.rc.inlineLinks = "" // the agent's default, relative to the working directory + active := mustStartup(t, h.rc) + if err := h.rc.activateInline(active); err != nil { + t.Fatal(err) + } + + const want = ".compliance-framework/policies/inline/ssh/policies" + path := active.runtime.inlinePolicyDirs["inline:ssh"] + if filepath.ToSlash(path) != want { + t.Fatalf("plugins receive %q, want %q", path, want) + } + inline, _ := r78Report(t, h) + if inline.PluginPath != path { + t.Fatalf("plugin-path = %q, want %q", inline.PluginPath, path) + } + for _, code := range []string{inlinepolicy.CodePolicyStreamForked, inlinepolicy.CodeContinuityPolicyIDSkipped, agentconfig.PolicyCodeDuplicatePolicyIdentity} { + if got := policyErrorsWithCode(h.remote.lastReport(t), code); len(got) != 0 { + t.Fatalf("unexpected %s: %+v", code, got) + } + } + if got, want := r78Evidence(t, path), r78Evidence(t, extracted); got != want { + t.Fatalf("inherited banner evidence UUID = %s, want the vendor's %s", got, want) + } +} diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 8012076..eff4fed 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -206,6 +206,9 @@ type reconciler struct { // resolvePolicy returns the policy root of an OCI or local policy source (downloading it // into the shared cache); it serves inline bundles' extends and the report inventory. resolvePolicy inlinepolicy.Resolver + // inlineLinks overrides inlineLinksDir, where plugins receive inline bundles (a test + // seam: the default is relative to the working directory). + inlineLinks string // pluginLib reads the agent library version of a prefetched plugin source (R76, R79); // nil skips the plugin compatibility checks and report. pluginLib pluginLibFunc diff --git a/cmd/remote_test.go b/cmd/remote_test.go index b167c31..0bd6656 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -188,6 +188,7 @@ func newRemoteHarness(t *testing.T, content string) *remoteHarness { // 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.inlineLinks = filepath.Join(h.dir, "policies", "inline") rc.newRemote = func(agentconfig.Config) remoteAPI { return h.remote } rc.now = h.clock.Now rc.lookupEnv = func(string) (string, bool) { return "", false } diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 41c86ff..a2ca0ff 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -103,8 +103,9 @@ relies on) warn. A pseudo-version counts as its base tag, since an untagged comm ### Stable inline paths (R67) `policy-manager` seeds evidence UUIDs with the policy file path. Materialized bundles stay write-once, -content-addressed directories (`//bundle`), but plugins receive `/current/bundle`, where `current` -is a symlink swapped with an atomic rename. The symlink is an intermediate path component on purpose: OPA's bundle +content-addressed directories (`/inline///policies`), but plugins receive +`.compliance-framework/policies/inline//policies` (R82; `/inline//current/bundle` before), where +`.compliance-framework/policies/inline/` is a symlink swapped with an atomic rename. The symlink is an intermediate path component on purpose: OPA's bundle loader does not descend into a symlinked root directory and would silently load nothing. The run loop swaps it only between two configuration runs, after the reload drain, and on a fallback; a plugin run therefore sees one tree from start to end, and its API helper resolves the path when the run starts, so evidence artifacts are the tree the run diff --git a/docs/configuration.md b/docs/configuration.md index 297c66a..0ac6fc4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -289,21 +289,31 @@ policy_bundles: involved, or changes one of the bundles involved) and warnings when they come from the file (R34), even under an overlay that changes something else. Replace the source with the inline bundle instead of listing both. -- **Overrides and evidence streams (R75).** Overriding a vendor module keeps its evidence stream only if the - override keeps the vendor module's `package` and continues its identity (see "Policy identity" below). A changed - `package` is a `policy-package-changed` warning; a `policy_id` that does not continue the vendor stream (missing, - removed or changed) is a `policy-stream-forked` warning, which names the `policy_id` that would continue it. +- **Overrides and evidence streams (R75, R82).** Inherited and overridden vendor modules keep their evidence + stream automatically: while materializing a bundle that `extends` a source, the agent appends + `policy_id := "/"` (or the vendor package's own `policy_id`, when it declares one) to + every non-test `compliance_framework.*` module that continues a vendor file and whose package declares no + `policy_id`: a module inherited unchanged, or an override at the same path that keeps the vendor module's `package`. + An explicit `policy_id` always wins. A package with more than one non-test module in the bundle is skipped with a + `policy-id-continuity-skipped` warning (one `policy_id` rule would name a single file for all of them); declare it + yourself in one module. The materialized tree, its digest, its artifact and the report's `files[]` include the + appended line; `extends.files[]` keeps the vendor's own hashes. A changed `package` is a `policy-package-changed` + warning; an explicit `policy_id` that does not continue the vendor stream is a `policy-stream-forked` warning, + which names the `policy_id` that would continue it. - **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any `.rego` file fails with `download-failed`. -- **Where bundles live (R67).** Inline bundles are written under the state directory and are never downloaded. Each - revision of a bundle is a write-once directory named by its tree digest, `/inline///bundle/`, - but plugins always receive the same path, `/inline//current/bundle`: `current` is a symlink the agent - swaps atomically to the running revision's directory, between two configuration runs (never while a plugin of the - previous configuration runs). Evidence UUIDs are seeded with the policy file path, so an unchanged package keeps its - evidence identity across edits of the bundle. On a file system without symlinks (Windows without the privilege), - plugins receive the digest directory itself and evidence identity changes with each revision, as before. - Directories no running, pending or fallback configuration uses are garbage-collected, never the one `current` - points to. +- **Where bundles live (R67, R82).** Inline bundles are never downloaded. Each revision of a bundle is a write-once + directory under the state directory named by its tree digest, `/inline///policies/`, but + plugins always receive the same relative path, `.compliance-framework/policies/inline//policies` (relative + to the agent's working directory, like an OCI source's path): `.compliance-framework/policies/inline/` is a + symlink the agent swaps atomically to the running revision's directory, between two configuration runs (never + while a plugin of the previous configuration runs). Evidence UUIDs are seeded with the policy file path, so an + unchanged package keeps its evidence identity across edits of the bundle, and a new module's stream does not + depend on the state directory. Two agents that share a working directory and use the same bundle name would swap + the same link: run one agent per working directory. On a file system without symlinks (Windows without the + privilege), plugins receive the digest directory itself and evidence identity changes with each revision. + Directories no running, pending or fallback configuration uses are garbage-collected, never the one the link + points to; trees and `current` links of the earlier `/inline//current/bundle` layout are removed. - **Plugin paths (R77).** Each `policy-bundles[]` entry of the configuration report carries `plugin-path`, the exact path string the agent passes plugins for that source: the stable path for an inline bundle, and the path the agent extracted an OCI source to, or a local source as configured. It is what a continuity `policy_id` is built diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index d6f1b41..6ff9bb4 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -103,7 +103,7 @@ volume). The agent looks the policy source up by the exact path it gave the plugin, which is the path the plugin reports the evaluation under. For an inline bundle that is the bundle's -stable path (`/inline//current/bundle`), so its evidence carries these props +stable path (`.compliance-framework/policies/inline//policies`, R82), so its evidence carries these props across revisions. A bundle an inline bundle `extends` is not on the evidence; the configuration report names it (`policy-bundles[].extends`). diff --git a/internal/inlinepolicy/activate_test.go b/internal/inlinepolicy/activate_test.go index 22ff4ec..157dc16 100644 --- a/internal/inlinepolicy/activate_test.go +++ b/internal/inlinepolicy/activate_test.go @@ -14,6 +14,12 @@ import ( "github.com/hashicorp/go-hclog" ) +// testLayout is the layout of the tests: the trees under root/store and the stable links +// under root/links. +func testLayout(root string) Layout { + return Layout{Store: filepath.Join(root, "store"), Links: filepath.Join(root, "links")} +} + func skipWithoutSymlinks(t *testing.T, root string) { t.Helper() if runtime.GOOS == "windows" || !symlinksSupported(root) { @@ -58,14 +64,14 @@ func TestActivate_EvidenceIdentityIsStable_R67(t *testing.T) { root := t.TempDir() skipWithoutSymlinks(t, root) revision := func(other string) *Materialized { - m, err := Materialize(context.Background(), root, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ + m, err := Materialize(context.Background(), testLayout(root), "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ "stable.rego": stablePkg, "other.rego": "package compliance_framework.other\n\ntitle := \"" + other + "\"\n", }}, nil) if err != nil { t.Fatal(err) } - if err := Activate(root, "ssh", m.Dir); err != nil { + if err := Activate(testLayout(root), "ssh", m.Dir); err != nil { t.Fatal(err) } return m @@ -79,7 +85,7 @@ func TestActivate_EvidenceIdentityIsStable_R67(t *testing.T) { if first.Dir == second.Dir || first.Path != second.Path { t.Fatalf("the trees must differ and the stable path must not: %s %s / %s %s", first.Dir, second.Dir, first.Path, second.Path) } - if want := filepath.Join(root, "ssh", "current", "bundle"); first.Path != want { + if want := filepath.Join(root, "links", "ssh", "policies"); first.Path != want { t.Fatalf("stable path = %s, want %s", first.Path, want) } const stable, other = "compliance_framework.stable", "compliance_framework.other" @@ -104,12 +110,13 @@ func TestActivate_EvidenceIdentityIsStable_R67(t *testing.T) { func TestActivate_RejectsForeignDirs(t *testing.T) { root := t.TempDir() skipWithoutSymlinks(t, root) - for _, dir := range []string{t.TempDir(), filepath.Join(root, "other", "abc", "bundle"), filepath.Join(root, "ssh", "abc")} { - if err := Activate(root, "ssh", dir); err == nil { + store := testLayout(root).Store + for _, dir := range []string{t.TempDir(), filepath.Join(store, "other", "abc", "policies"), filepath.Join(store, "ssh", "abc"), filepath.Join(store, "ssh", "abc", "bundle")} { + if err := Activate(testLayout(root), "ssh", dir); err == nil { t.Fatalf("activating %s must fail", dir) } } - if err := Activate(root, "ssh", filepath.Join(root, "ssh", "abc", "bundle")); err == nil { + if err := Activate(testLayout(root), "ssh", filepath.Join(store, "ssh", "abc", "policies")); err == nil { t.Fatal("activating a missing tree must fail") } } @@ -121,7 +128,7 @@ func TestGC_NeverRemovesCurrent(t *testing.T) { skipWithoutSymlinks(t, root) var dirs []string for _, title := range []string{"a", "b", "c"} { - m, err := Materialize(context.Background(), root, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ + m, err := Materialize(context.Background(), testLayout(root), "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ "x.rego": "package compliance_framework.x\n\ntitle := \"" + title + "\"\n", }}, nil) if err != nil { @@ -129,14 +136,15 @@ func TestGC_NeverRemovesCurrent(t *testing.T) { } dirs = append(dirs, m.Dir) } - if err := Activate(root, "ssh", dirs[0]); err != nil { + if err := Activate(testLayout(root), "ssh", dirs[0]); err != nil { t.Fatal(err) } // A crash between creating and renaming the temporary link leaves it behind. - if err := os.Symlink("x", filepath.Join(root, "ssh", currentTmpPrefix+"stale")); err != nil { + stale := filepath.Join(root, "links", tmpPrefix+"ssh-stale") + if err := os.Symlink("x", stale); err != nil { t.Fatal(err) } - if err := GC(root, nil, 0); err != nil { + if err := GC(testLayout(root), nil, 0); err != nil { t.Fatal(err) } if _, err := os.Stat(dirs[0]); err != nil { @@ -147,10 +155,10 @@ func TestGC_NeverRemovesCurrent(t *testing.T) { t.Fatalf("%s should have been collected", d) } } - if _, err := os.Lstat(filepath.Join(root, "ssh", currentTmpPrefix+"stale")); !os.IsNotExist(err) { + if _, err := os.Lstat(stale); !os.IsNotExist(err) { t.Fatal("a stale temporary link must be removed") } - if _, err := os.Stat(filepath.Join(root, "ssh", "current", "bundle", "x.rego")); err != nil { + if _, err := os.Stat(filepath.Join(root, "links", "ssh", "policies", "x.rego")); err != nil { t.Fatalf("the stable path must still resolve: %v", err) } } @@ -168,7 +176,7 @@ func TestActivate_SwapIsAtomic(t *testing.T) { skipWithoutSymlinks(t, root) var trees [2]*Materialized for i, title := range []string{"a", "b"} { - m, err := Materialize(context.Background(), root, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ + m, err := Materialize(context.Background(), testLayout(root), "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ "x.rego": "package compliance_framework.x\n\ntitle := \"" + title + "\"\n", }}, nil) if err != nil { @@ -176,7 +184,7 @@ func TestActivate_SwapIsAtomic(t *testing.T) { } trees[i] = m } - if err := Activate(root, "ssh", trees[0].Dir); err != nil { + if err := Activate(testLayout(root), "ssh", trees[0].Dir); err != nil { t.Fatal(err) } want := map[string]bool{} @@ -207,7 +215,7 @@ func TestActivate_SwapIsAtomic(t *testing.T) { }() } for i := range 500 { - if err := Activate(root, "ssh", trees[(i+1)%2].Dir); err != nil { + if err := Activate(testLayout(root), "ssh", trees[(i+1)%2].Dir); err != nil { t.Error(err) break } @@ -220,11 +228,11 @@ func TestActivate_SwapIsAtomic(t *testing.T) { } // TestMaterialize_RebuildsOldLayout: a tree directory from the layout before R67 (files -// directly under ) is rebuilt instead of being reused without its bundle/ directory. +// directly under ) is rebuilt instead of being reused without its policies/ directory. func TestMaterialize_RebuildsOldLayout(t *testing.T) { root := t.TempDir() b := &agentconfig.PolicyBundle{Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n"}} - m, err := Materialize(context.Background(), root, "ssh", b, nil) + m, err := Materialize(context.Background(), testLayout(root), "ssh", b, nil) if err != nil { t.Fatal(err) } @@ -238,14 +246,14 @@ func TestMaterialize_RebuildsOldLayout(t *testing.T) { if err := os.WriteFile(filepath.Join(version, "x.rego"), []byte("old"), 0o644); err != nil { t.Fatal(err) } - // A vendor tree with a top-level bundle/ directory must not pass for the new layout. - if err := os.MkdirAll(filepath.Join(version, "bundle"), 0o755); err != nil { + // A vendor tree with a top-level policies/ directory must not pass for the new layout. + if err := os.MkdirAll(filepath.Join(version, "policies"), 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(version, "bundle", "other.rego"), []byte("old"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(version, "policies", "other.rego"), []byte("old"), 0o644); err != nil { t.Fatal(err) } - again, err := Materialize(context.Background(), root, "ssh", b, nil) + again, err := Materialize(context.Background(), testLayout(root), "ssh", b, nil) if err != nil { t.Fatal(err) } diff --git a/internal/inlinepolicy/continuity_test.go b/internal/inlinepolicy/continuity_test.go new file mode 100644 index 0000000..be846a4 --- /dev/null +++ b/internal/inlinepolicy/continuity_test.go @@ -0,0 +1,294 @@ +package inlinepolicy + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/hashicorp/go-hclog" + "github.com/open-policy-agent/opa/v1/ast" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// R82: inherited and overridden modules of an inline bundle keep the vendor's evidence +// streams without any policy_id written by the user, and plugins receive the bundle at a +// relative, local-source-style path. + +const ( + // r82Vendor is where the agent extracts the vendor OCI bundle: relative to the working + // directory, ending in the artifact's policies directory. + r82Vendor = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" + // r82Links is where the agent links inline bundles (cmd's inlineLinksDir). + r82Links = ".compliance-framework/policies/inline" +) + +var r82VendorFiles = map[string]string{ + "ssh/require_key_based_ssh.rego": "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH\"\n\nviolation contains {\"id\": \"k\"} if input.password\n", + "ssh/deny_root_login.rego": "package compliance_framework.deny_root_login\n\nimport rego.v1\n\ntitle := \"No root login\"\n\nviolation contains {\"id\": \"r\"} if input.root\n", + "banner.rego": "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"b\"} if not input.banner\n", + "ssh/require_key_based_ssh_test.rego": "package compliance_framework.require_key_based_ssh_test\n\nimport rego.v1\n\ntest_ok if true\n", + "lib/helpers.rego": "package ccf_libs.helpers\n\nimport rego.v1\n\nyes := true\n", +} + +// r82Setup writes the vendor tree at r82Vendor in a new working directory and returns the +// layout the agent uses there (the store under the state directory, given absolute as +// CCF_STATE_DIR would) and a resolver that returns the literal relative vendor path. +func r82Setup(t *testing.T) (Layout, Resolver) { + t.Helper() + wd := t.TempDir() + t.Chdir(wd) + skipWithoutSymlinks(t, wd) + for p, src := range r82VendorFiles { + dst := filepath.Join(r82Vendor, filepath.FromSlash(p)) + require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) + require.NoError(t, os.WriteFile(dst, []byte(src), 0o644)) + } + l := Layout{Store: filepath.Join(wd, ".compliance-framework", "state", "local-dev", "inline"), Links: r82Links} + return l, func(_ context.Context, source string) (string, error) { + require.Equal(t, "ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0", source) + return r82Vendor, nil + } +} + +// r82Materialize materializes and activates bundle "custom" with modules over the vendor. +func r82Materialize(t *testing.T, l Layout, resolve Resolver, modules map[string]string) *Materialized { + t.Helper() + m, err := Materialize(context.Background(), l, "custom", &agentconfig.PolicyBundle{ + Extends: strptr("ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0"), + Modules: modules, + }, resolve) + require.NoError(t, err) + require.NoError(t, Activate(l, "custom", m.Dir)) + return m +} + +type r82Evidence struct { + uuid string + labels map[string]string +} + +// r82Run evaluates policyPath the way the ssh plugin does (labels with _policy_path) and +// returns each package's evidence. +func r82Run(t *testing.T, policyPath string) map[string]r82Evidence { + t.Helper() + labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-4", "_policy_path": policyPath} + evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). + GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) + require.NoError(t, err) + out := map[string]r82Evidence{} + for _, e := range evidence { + out[e.Labels["_policy"]] = r82Evidence{e.UUID, e.Labels} + } + require.NotEmpty(t, out, "no evidence at %s", policyPath) + return out +} + +// TestR82_InheritedModulesKeepTheVendorStream: adding a module to a bundle that extends the +// vendor leaves every inherited module on its vendor evidence UUID. +func TestR82_InheritedModulesKeepTheVendorStream(t *testing.T) { + l, resolve := r82Setup(t) + m := r82Materialize(t, l, resolve, map[string]string{ + "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", + }) + + // (5) The plugin path is the relative, local-source-style path, and the tree loads. + assert.Equal(t, ".compliance-framework/policies/inline/custom/policies", filepath.ToSlash(m.Path)) + got := r82Run(t, m.Path) + assert.Len(t, got, 4, "the three vendor policies and the new one") + + vendor := r82Run(t, r82Vendor) + for _, pkg := range []string{"compliance_framework.require_key_based_ssh", "compliance_framework.deny_root_login", "compliance_framework.banner"} { + require.Contains(t, got, pkg) + assert.Equal(t, vendor[pkg].uuid, got[pkg].uuid, "%s must continue the vendor stream", pkg) + assert.Equal(t, m.Path, got[pkg].labels["_policy_path"], "the evidence keeps the real _policy_path label") + assert.NotEmpty(t, got[pkg].labels["_policy_id"], "the evidence records the continuity policy_id") + } + assert.Equal(t, r82Vendor+"/ssh/require_key_based_ssh.rego", got["compliance_framework.require_key_based_ssh"].labels["_policy_id"]) + + newEvidence := got["compliance_framework.custom_new"] + assert.NotContains(t, newEvidence.labels, "_policy_id", "a new module keeps a path-based stream") + assert.Equal(t, m.Path, newEvidence.labels["_policy_path"]) + + assert.Equal(t, map[string]string{ + "banner.rego": r82Vendor + "/banner.rego", + "ssh/deny_root_login.rego": r82Vendor + "/ssh/deny_root_login.rego", + "ssh/require_key_based_ssh.rego": r82Vendor + "/ssh/require_key_based_ssh.rego", + }, m.Continued, "only inherited compliance_framework modules, not tests, libraries or new modules") + assert.Empty(t, OverrideStreams(m)) + + // The report inventories the tree as written, so its files match the digest and the + // uploaded artifact; the vendor files stay in the extends report. + for _, f := range m.Files { + if f.Path == "banner.rego" { + assert.NotEqual(t, sha(r82VendorFiles["banner.rego"]), f.SHA256) + } + } + for _, f := range m.Extends.Files { + if f.Path == "banner.rego" { + assert.Equal(t, sha(r82VendorFiles["banner.rego"]), f.SHA256) + } + } + + // Another revision of the bundle keeps the path and the streams (R67). + again := r82Materialize(t, l, resolve, map[string]string{ + "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New v2\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", + }) + assert.NotEqual(t, m.Dir, again.Dir) + assert.Equal(t, m.Path, again.Path) + after := r82Run(t, again.Path) + for pkg, e := range got { + assert.Equal(t, e.uuid, after[pkg].uuid, "%s keeps its stream across revisions", pkg) + } +} + +// TestR82_Overrides: an override at the vendor path with the vendor's package continues the +// vendor stream without a policy_id; one with another package starts a new stream and is +// warned about; an explicit policy_id always wins. +func TestR82_Overrides(t *testing.T) { + const path = "ssh/require_key_based_ssh.rego" + const pkg = "compliance_framework.require_key_based_ssh" + t.Run("same package, no policy_id", func(t *testing.T) { + l, resolve := r82Setup(t) + src := "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH (tuned)\"\n\nviolation contains {\"id\": \"k2\"} if input.password\n" + m := r82Materialize(t, l, resolve, map[string]string{path: src}) + assert.Equal(t, withContinuity(src, r82Vendor, path), readFile(t, m.Dir, path)) + assert.Equal(t, r82Run(t, r82Vendor)[pkg].uuid, r82Run(t, m.Path)[pkg].uuid) + assert.Empty(t, OverrideStreams(m), "no policy-stream-forked warning for a module that now continues") + }) + t.Run("changed package", func(t *testing.T) { + l, resolve := r82Setup(t) + src := "package compliance_framework.require_key_based_ssh_v2\n\nimport rego.v1\n\ntitle := \"Key based SSH v2\"\n\nviolation contains {\"id\": \"k2\"} if input.password\n" + m := r82Materialize(t, l, resolve, map[string]string{path: src}) + assert.Equal(t, src, readFile(t, m.Dir, path), "a changed package gets no continuity policy_id") + got := r82Run(t, m.Path) + require.Contains(t, got, pkg+"_v2") + vendorUUID := r82Run(t, r82Vendor)[pkg].uuid + assert.NotEqual(t, vendorUUID, got[pkg+"_v2"].uuid) + warnings := OverrideStreams(m) + require.Len(t, warnings, 1) + assert.Equal(t, agentconfig.PolicyCodePolicyPackageChanged, warnings[0].Code) + assert.Equal(t, path, warnings[0].Path) + }) + t.Run("explicit policy_id", func(t *testing.T) { + l, resolve := r82Setup(t) + src := "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\npolicy_id := \"ssh-key-based\"\n\ntitle := \"Key based SSH\"\n\nviolation contains {\"id\": \"k\"} if input.password\n" + m := r82Materialize(t, l, resolve, map[string]string{path: src}) + assert.Equal(t, src, readFile(t, m.Dir, path), "an explicit policy_id is left alone") + assert.NotContains(t, m.Continued, path) + got := r82Run(t, m.Path)[pkg] + assert.Equal(t, "ssh-key-based", got.labels["_policy_id"]) + assert.NotEqual(t, r82Run(t, r82Vendor)[pkg].uuid, got.uuid) + warnings := OverrideStreams(m) + require.Len(t, warnings, 1, "an explicit policy_id that forks the stream is still warned about") + assert.Equal(t, CodePolicyStreamForked, warnings[0].Code) + }) +} + +// TestR82_MultiModulePackageIsSkipped: a package with two non-test modules in the bundle +// gets no policy_id (it would name one file for both), and a warning says so. +func TestR82_MultiModulePackageIsSkipped(t *testing.T) { + l, resolve := r82Setup(t) + extra := "package compliance_framework.banner\n\nimport rego.v1\n\nbanner_text := \"hello\"\n" + m := r82Materialize(t, l, resolve, map[string]string{"banner_extra.rego": extra}) + assert.Equal(t, r82VendorFiles["banner.rego"], readFile(t, m.Dir, "banner.rego")) + assert.Equal(t, extra, readFile(t, m.Dir, "banner_extra.rego")) + assert.NotContains(t, m.Continued, "banner.rego") + assert.Contains(t, m.Continued, "ssh/deny_root_login.rego", "other packages still continue") + + var skipped []agentconfig.PolicyError + for _, w := range m.Warnings { + if w.Code == CodeContinuityPolicyIDSkipped { + skipped = append(skipped, w) + } + } + require.Len(t, skipped, 1) + assert.Equal(t, "banner.rego", skipped[0].Path) + assert.Equal(t, agentconfig.SeverityWarning, skipped[0].Severity) + assert.Contains(t, skipped[0].Message, "banner.rego, banner_extra.rego") + assert.Contains(t, skipped[0].Message, `policy_id := "`+r82Vendor+`/banner.rego"`) + + vendorUUID := r82Run(t, r82Vendor)["compliance_framework.banner"].uuid + assert.NotContains(t, r82UUIDs(t, m.Path, "compliance_framework.banner"), vendorUUID) + + // A policy_id the user declares in one module of the package continues the stream of + // the vendor file (plugins evaluate each module of a package, so banner.rego's + // evidence is the vendor's and banner_extra.rego's has its own). + fixed := extra + "\npolicy_id := \"" + r82Vendor + "/banner.rego\"\n" + m = r82Materialize(t, l, resolve, map[string]string{"banner_extra.rego": fixed}) + for _, w := range m.Warnings { + assert.NotEqual(t, CodeContinuityPolicyIDSkipped, w.Code) + } + assert.Contains(t, r82UUIDs(t, m.Path, "compliance_framework.banner"), vendorUUID) +} + +// r82UUIDs returns the evidence UUIDs of package pkg at policyPath (one per module). +func r82UUIDs(t *testing.T, policyPath, pkg string) []string { + t.Helper() + labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-4", "_policy_path": policyPath} + evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). + GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) + require.NoError(t, err) + var out []string + for _, e := range evidence { + if e.Labels["_policy"] == pkg { + out = append(out, e.UUID) + } + } + return out +} + +// TestR82_RegoV0Module: a vendor module only Rego v0 parses still gets the policy_id +// (detected with the v0 parser; the appended rule is valid in both). +func TestR82_RegoV0Module(t *testing.T) { + const v0 = "package compliance_framework.legacy\n\ntitle = \"Legacy\"\n\nviolation[{\"id\": \"l\"}] {\n input.password\n}\n" + _, err := ast.ParseModuleWithOpts("legacy.rego", v0, ast.ParserOptions{RegoVersion: ast.RegoV1}) + require.Error(t, err, "the fixture must be v0-only") + + m := &Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "s"}, ExtendsDir: "vendor", Continued: map[string]string{}} + tree := map[string][]byte{"legacy.rego": []byte(v0)} + assert.Empty(t, continueVendorStreams(m, tree, map[string][]byte{"legacy.rego": []byte(v0)})) + assert.Equal(t, withContinuity(v0, "vendor", "legacy.rego"), string(tree["legacy.rego"])) + assert.Equal(t, map[string]string{"legacy.rego": "vendor/legacy.rego"}, m.Continued) +} + +// TestR82_MigratesTheR67Layout: the R67 layout (//current -> with the tree +// in bundle/) is replaced: Materialize writes the new layout and GC removes the old trees +// and the current link. +func TestR82_MigratesTheR67Layout(t *testing.T) { + root := t.TempDir() + skipWithoutSymlinks(t, root) + l := testLayout(root) + old := filepath.Join(l.Store, "ssh", "0123abcd") + require.NoError(t, os.MkdirAll(filepath.Join(old, "bundle"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(old, "bundle", "x.rego"), []byte("package compliance_framework.x\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(old, treeMarker), nil, 0o644)) + require.NoError(t, os.Symlink("0123abcd", filepath.Join(l.Store, "ssh", legacyCurrentLink))) + + m, err := Materialize(context.Background(), l, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n"}}, nil) + require.NoError(t, err) + require.NoError(t, Activate(l, "ssh", m.Dir)) + require.NoError(t, GC(l, map[string]struct{}{m.Dir: {}}, 5)) + + _, err = os.Stat(old) + assert.True(t, os.IsNotExist(err), "the R67 tree must be collected") + _, err = os.Lstat(filepath.Join(l.Store, "ssh", legacyCurrentLink)) + assert.True(t, os.IsNotExist(err), "the R67 current link must be removed") + assert.Equal(t, "package compliance_framework.x\n\ntitle := \"x\"\n", readFile(t, m.Path, "x.rego")) + info, err := os.Lstat(filepath.Join(l.Links, "ssh")) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink, "the bundle's link is a symlink") + info, err = os.Lstat(filepath.Join(l.Links, "ssh", "policies")) + require.NoError(t, err) + assert.True(t, info.IsDir(), "policies/ is a real directory inside the linked tree") +} + +func sha(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/inlinepolicy/identity.go b/internal/inlinepolicy/identity.go index 7b5e11d..b4c2e5c 100644 --- a/internal/inlinepolicy/identity.go +++ b/internal/inlinepolicy/identity.go @@ -1,8 +1,10 @@ package inlinepolicy import ( + "encoding/json" "fmt" "path/filepath" + "slices" "strings" "github.com/compliance-framework/api/pkg/agentconfig" @@ -63,8 +65,8 @@ func authoredSites(files map[string][]byte, authored map[string]bool) (setViolat if !authored[p] || !strings.HasSuffix(p, ".rego") || policyeval.IsTestFile(p) { continue } - mod, err := ast.ParseModuleWithOpts(p, string(files[p]), ast.ParserOptions{RegoVersion: ast.RegoV1}) - if err != nil || mod == nil || !policyeval.IsPolicyPackage(packageOf(mod)) { + mod := parseRego(p, files[p]) + if mod == nil || !policyeval.IsPolicyPackage(packageOf(mod)) { continue } var setSite, idSite *Site @@ -173,14 +175,134 @@ func OverrideStreams(m *Materialized) []agentconfig.PolicyError { // continue the evidence stream of the vendor module it replaces (R75). Warning. const CodePolicyStreamForked = "policy-stream-forked" +// CodeContinuityPolicyIDSkipped is the PolicyError code of a module that continues a vendor +// file but that the agent could not give a continuity policy_id (R82), so plugins that load +// the bundle instead of the extends source record its evidence in a new stream. Warning. +const CodeContinuityPolicyIDSkipped = "policy-id-continuity-skipped" + +// continueVendorStreams appends a continuity policy_id (R82) to every module of files, the +// tree of bundle m that extends vendor, that continues a vendor file and whose package +// declares no policy_id, so plugins loading the bundle in place of the extends source keep +// recording the vendor module's evidence stream: +// +// - a module inherited from the vendor tree unchanged, or +// - an authored module at the path of a vendor module, with the vendor module's package. +// +// The policy_id is the vendor package's own policy_id when it declares one, and otherwise +// ContinuityPolicyID of the extends source's plugin path and the module's path: the stream +// the vendor module had. Only non-test modules of compliance_framework packages count +// (others record no evidence). A package with more than one non-test module in the bundle is +// skipped with a warning: one complete policy_id rule would name a single file for all of +// them, and two would conflict. It records what it appended in m.Continued and returns the +// warnings. +func continueVendorStreams(m *Materialized, files, vendor map[string][]byte) []agentconfig.PolicyError { + var warnings []agentconfig.PolicyError + warn := func(p, format string, args ...any) { + warnings = append(warnings, agentconfig.PolicyError{Bundle: m.Name, Path: p, Severity: agentconfig.SeverityWarning, + Code: CodeContinuityPolicyIDSkipped, Message: fmt.Sprintf(format, args...)}) + } + modules := parseModules(files) + vendorModules := parseModules(vendor) + vendorIDs := policyIDs(vendorModules) + + // Per package: its non-test modules, and whether one of them declares (or imports as) + // policy_id, which always wins. + pkgModules := map[string][]string{} + declared := map[string]bool{} + for _, p := range sortedKeys(modules) { + if policyeval.IsTestFile(p) { + continue + } + pkg := packageOf(modules[p]) + pkgModules[pkg] = append(pkgModules[pkg], p) + if declaresPolicyID(modules[p]) { + declared[pkg] = true + } + } + + for _, p := range sortedKeys(modules) { + mod := modules[p] + pkg := packageOf(mod) + if policyeval.IsTestFile(p) || !policyeval.IsPolicyPackage(pkg) || declared[pkg] { + continue + } + vmod, fromVendor := vendorModules[p] + if !fromVendor { + continue // a new module: its own stream + } + if m.Authored[p] && packageOf(vmod) != pkg { + continue // a changed package starts a new stream (OverrideStreams warns) + } + id := vendorIDs[pkg] + if id == "" { + id = ContinuityPolicyID(m.ExtendsDir, p) + } + if others := pkgModules[pkg]; len(others) > 1 { + warn(p, "package %s has %d modules in the bundle (%s), so the agent cannot declare its policy_id for %s; plugins that load this bundle instead of %s record its evidence in a new stream. Declare `policy_id := %q` in one module of the package to continue the vendor policy's stream", + pkg, len(others), strings.Join(others, ", "), p, m.Extends.Source, id) + continue + } + if !policyeval.ValidPolicyID(id) { + warn(p, "the policy_id that continues the vendor stream of %s would not be valid (%d characters), so plugins that load this bundle instead of %s record its evidence in a new stream", p, len([]rune(id)), m.Extends.Source) + continue + } + literal, err := json.Marshal(id) + if err != nil { + continue + } + src := append(slices.Clip(files[p]), []byte("\npolicy_id := "+string(literal)+"\n")...) + // The module must still parse and now declare exactly this policy_id. + if got := parseRego(p, src); got == nil || policyIDs(map[string]*ast.Module{p: got})[pkg] != id { + warn(p, "the agent could not append the policy_id that continues the vendor stream of %s; plugins that load this bundle instead of %s record its evidence in a new stream", p, m.Extends.Source) + continue + } + files[p] = src + m.Continued[p] = id + } + return warnings +} + +// declaresPolicyID reports whether mod defines a rule named policy_id, in any form, or +// imports something as policy_id: either way an appended policy_id rule would not be the +// package's only one. +func declaresPolicyID(mod *ast.Module) bool { + for _, rule := range mod.Rules { + if ruleName(rule) == "policy_id" { + return true + } + } + for _, imp := range mod.Imports { + if imp.Alias == "policy_id" { + return true + } + if ref, ok := imp.Path.Value.(ast.Ref); ok && len(ref) > 1 { + if s, ok := ref[len(ref)-1].Value.(ast.String); ok && string(s) == "policy_id" && imp.Alias == "" { + return true + } + } + } + return false +} + +// parseRego parses a module as Rego v1, then as Rego v0, as plugins on either OPA major +// would load it; nil when it does not parse or has no package. +func parseRego(p string, src []byte) *ast.Module { + for _, v := range []ast.RegoVersion{ast.RegoV1, ast.RegoV0} { + mod, err := ast.ParseModuleWithOpts(p, string(src), ast.ParserOptions{RegoVersion: v}) + if err == nil && mod != nil && mod.Package != nil { + return mod + } + } + return nil +} + func parseModules(files map[string][]byte) map[string]*ast.Module { out := map[string]*ast.Module{} for p, src := range files { if !strings.HasSuffix(p, ".rego") { continue } - mod, err := ast.ParseModuleWithOpts(p, string(src), ast.ParserOptions{RegoVersion: ast.RegoV1}) - if err == nil && mod != nil && mod.Package != nil { + if mod := parseRego(p, src); mod != nil { out[p] = mod } } diff --git a/internal/inlinepolicy/identity_test.go b/internal/inlinepolicy/identity_test.go index 623539d..7b5958b 100644 --- a/internal/inlinepolicy/identity_test.go +++ b/internal/inlinepolicy/identity_test.go @@ -63,15 +63,18 @@ func TestOverrideStreams_R75(t *testing.T) { }{ {"continues the legacy stream", map[string]string{"banner.rego": continuing}, map[string]string{}}, {"keeps the vendor policy_id", map[string]string{"ided.rego": vendorID + "\n# changed\n"}, map[string]string{}}, - {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{"banner.rego": CodePolicyStreamForked}}, + // R82: the agent appends the continuity policy_id to an override without one. + {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{}}, + {"no policy_id in a package of two modules", map[string]string{"banner.rego": vendorBanner + "\n# changed\n", "banner_more.rego": "package compliance_framework.banner\n\nmore := true\n"}, map[string]string{"banner.rego": CodePolicyStreamForked}}, {"changed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\npolicy_id := \"other\"\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, - {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, + // R82: the agent appends the vendor package's policy_id. + {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{}}, {"changed package", map[string]string{"max_auth.rego": "package compliance_framework.max_auth_v2\n\ntitle := \"x\"\n"}, map[string]string{"max_auth.rego": agentconfig.PolicyCodePolicyPackageChanged}}, {"new module", map[string]string{"new.rego": "package compliance_framework.new\n\ntitle := \"x\"\n"}, map[string]string{}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: tc.modules}, resolve) + m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: tc.modules}, resolve) require.NoError(t, err) got := map[string]string{} for _, e := range OverrideStreams(m) { @@ -82,7 +85,7 @@ func TestOverrideStreams_R75(t *testing.T) { assert.Equal(t, tc.want, got) }) } - m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Modules: map[string]string{"banner.rego": vendorBanner}}, resolve) + m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Modules: map[string]string{"banner.rego": vendorBanner}}, resolve) require.NoError(t, err) assert.Empty(t, OverrideStreams(m), "a bundle without extends overrides nothing") } @@ -104,7 +107,7 @@ func TestOverrideStreams_NonCleanLocalSource_R77(t *testing.T) { override := func(id string) []agentconfig.PolicyError { t.Helper() src := "package compliance_framework.banner\n\npolicy_id := \"" + id + "\"\n\ntitle := \"Banner\"\n" - m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("local"), Modules: map[string]string{"banner.rego": src}}, resolve) + m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("local"), Modules: map[string]string{"banner.rego": src}}, resolve) require.NoError(t, err) return OverrideStreams(m) } diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go index f511c96..2a30c7a 100644 --- a/internal/inlinepolicy/inlinepolicy_test.go +++ b/internal/inlinepolicy/inlinepolicy_test.go @@ -55,8 +55,14 @@ func readFile(t *testing.T, dir, p string) string { const vendorBanner = "package compliance_framework.banner\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"no-banner\", \"remarks\": \"no banner\"} if not input.banner\n" const vendorMaxAuth = "package compliance_framework.max_auth\n\nviolation contains {\"remarks\": \"too many\"} if input.max_auth > 3\n" +// withContinuity is src with the continuity policy_id the agent appends to a module at rel +// that continues the vendor file at rel under pluginPath (R82). +func withContinuity(src, pluginPath, rel string) string { + return src + "\npolicy_id := \"" + pluginPath + "/" + rel + "\"\n" +} + func TestMaterialize_R17Order(t *testing.T) { - _, resolve := vendorTree(t, map[string]string{ + vendor, resolve := vendorTree(t, map[string]string{ "banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth, "legacy.rego": "package compliance_framework.legacy\n", @@ -81,18 +87,31 @@ func TestMaterialize_R17Order(t *testing.T) { Data: map[string]any{"limits": map[string]any{"max_auth": 5, "keep": nil}}, } root := t.TempDir() - m, err := Materialize(context.Background(), root, "ssh", b, resolve) + m, err := Materialize(context.Background(), testLayout(root), "ssh", b, resolve) if err != nil { t.Fatalf("materialize: %v", err) } if _, err := os.Stat(filepath.Join(m.Dir, "legacy.rego")); !os.IsNotExist(err) { t.Fatal("deleted vendor module must be gone") } - if got := readFile(t, m.Dir, "max_auth.rego"); !strings.Contains(got, "# override") { - t.Fatalf("override not applied: %q", got) + if got := readFile(t, m.Dir, "max_auth.rego"); got != withContinuity(b.Modules["max_auth.rego"], vendor, "max_auth.rego") { + t.Fatalf("override not applied, or without the continuity policy_id: %q", got) + } + if got := readFile(t, m.Dir, "banner.rego"); got != withContinuity(vendorBanner, vendor, "banner.rego") { + t.Fatalf("inherited module changed beyond the continuity policy_id: %q", got) + } + if got := readFile(t, m.Dir, "lib/helpers.rego"); got != "package ccf_libs.helpers\n" { + t.Fatalf("a library package records no evidence and gets no policy_id: %q", got) } - if got := readFile(t, m.Dir, "banner.rego"); got != vendorBanner { - t.Fatalf("inherited module changed: %q", got) + if got := readFile(t, m.Dir, "banner_test.rego"); got != "package compliance_framework.banner_test\n" { + t.Fatalf("a test module gets no policy_id: %q", got) + } + if got := readFile(t, m.Dir, "extra/new.rego"); got != b.Modules["extra/new.rego"] { + t.Fatalf("a new module starts its own stream and gets no policy_id: %q", got) + } + wantContinued := map[string]string{"banner.rego": vendor + "/banner.rego", "max_auth.rego": vendor + "/max_auth.rego", "nested/deep/a.rego": vendor + "/nested/deep/a.rego"} + if !reflect.DeepEqual(m.Continued, wantContinued) { + t.Fatalf("continued = %v, want %v", m.Continued, wantContinued) } readFile(t, m.Dir, "extra/new.rego") readFile(t, m.Dir, "Policies/Max.Auth.rego") @@ -128,7 +147,7 @@ func TestMaterialize_R17Order(t *testing.T) { } } - again, err := Materialize(context.Background(), root, "ssh", b, resolve) + again, err := Materialize(context.Background(), testLayout(root), "ssh", b, resolve) if err != nil || again.Dir != m.Dir { t.Fatalf("the same content must reuse the same directory: %v %s %s", err, again.Dir, m.Dir) } @@ -144,7 +163,7 @@ func contains(list []string, s string) bool { } func TestMaterialize_OverlayNullRestoresVendorModule(t *testing.T) { - _, resolve := vendorTree(t, map[string]string{"max_auth.rego": vendorMaxAuth}) + vendor, resolve := vendorTree(t, map[string]string{"max_auth.rego": vendorMaxAuth}) base := agentconfig.Config{PolicyBundles: map[string]*agentconfig.PolicyBundle{"ssh": { Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{"max_auth.rego": "package compliance_framework.max_auth\n# file override\n"}, @@ -153,11 +172,11 @@ func TestMaterialize_OverlayNullRestoresVendorModule(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := Materialize(context.Background(), t.TempDir(), "ssh", merged.PolicyBundles["ssh"], resolve) + m, err := Materialize(context.Background(), testLayout(t.TempDir()), "ssh", merged.PolicyBundles["ssh"], resolve) if err != nil { t.Fatal(err) } - if got := readFile(t, m.Dir, "max_auth.rego"); got != vendorMaxAuth { + if got := readFile(t, m.Dir, "max_auth.rego"); got != withContinuity(vendorMaxAuth, vendor, "max_auth.rego") { t.Fatalf("null must restore the vendor module, got %q", got) } } @@ -176,7 +195,7 @@ func TestMaterialize_Errors(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := Materialize(context.Background(), t.TempDir(), "b", tt.b, resolve) + _, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", tt.b, resolve) var perrs PolicyErrors if !errors.As(err, &perrs) || !agentconfig.HasPolicyErrors(perrs) { t.Fatalf("expected policy errors, got %v", err) @@ -184,7 +203,7 @@ func TestMaterialize_Errors(t *testing.T) { }) } t.Run("resolver failure", func(t *testing.T) { - _, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/other:v1")}, resolve) + _, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/other:v1")}, resolve) if !errors.Is(err, ErrResolve) { t.Fatalf("expected ErrResolve, got %v", err) } @@ -202,7 +221,7 @@ func TestMaterialize_ExtendsRootSymlinkAndEmptyTree(t *testing.T) { t.Fatal(err) } resolve := func(context.Context, string) (string, error) { return link, nil } - m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("/etc/ccf/policies")}, resolve) + m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("/etc/ccf/policies")}, resolve) if err != nil { t.Fatal(err) } @@ -212,7 +231,7 @@ func TestMaterialize_ExtendsRootSymlinkAndEmptyTree(t *testing.T) { }) t.Run("a tree without .rego files is an error", func(t *testing.T) { _, resolve := vendorTree(t, map[string]string{"README.md": "nothing here"}) - _, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) + _, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) if !errors.Is(err, ErrResolve) || !strings.Contains(err.Error(), "no .rego files") { t.Fatalf("expected an ErrResolve for an empty extends tree, got %v", err) } @@ -231,7 +250,7 @@ func TestMaterialize_SkipsSymlinksInExtends(t *testing.T) { if err := os.Symlink(secret, filepath.Join(dir, "link.rego")); err != nil { t.Fatal(err) } - m, err := Materialize(context.Background(), t.TempDir(), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) + m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) if err != nil { t.Fatal(err) } @@ -246,7 +265,7 @@ func TestMaterialize_SkipsSymlinksInExtends(t *testing.T) { func materialize(t *testing.T, vendor map[string]string, b *agentconfig.PolicyBundle) *Materialized { t.Helper() _, resolve := vendorTree(t, vendor) - m, err := Materialize(context.Background(), t.TempDir(), "b", b, resolve) + m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", b, resolve) if err != nil { t.Fatalf("materialize: %v", err) } @@ -457,8 +476,11 @@ func TestGC_KeepsActiveAndNewest(t *testing.T) { root := t.TempDir() var dirs []string for i := 0; i < 8; i++ { - d := filepath.Join(root, "ssh", strings.Repeat(string(rune('a'+i)), 4)) - if err := os.MkdirAll(d, 0o755); err != nil { + d := filepath.Join(root, "store", "ssh", strings.Repeat(string(rune('a'+i)), 4)) + if err := os.MkdirAll(filepath.Join(d, treeDir), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, treeMarker), nil, 0o644); err != nil { t.Fatal(err) } mod := time.Now().Add(time.Duration(i) * time.Minute) @@ -467,11 +489,11 @@ func TestGC_KeepsActiveAndNewest(t *testing.T) { } dirs = append(dirs, d) } - if err := os.MkdirAll(filepath.Join(root, "ssh", ".tmp-abc"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(root, "store", "ssh", ".tmp-abc"), 0o755); err != nil { t.Fatal(err) } keep := map[string]struct{}{dirs[0]: {}} // the oldest is active - if err := GC(root, keep, 5); err != nil { + if err := GC(testLayout(root), keep, 5); err != nil { t.Fatal(err) } for i, d := range dirs { @@ -482,7 +504,7 @@ func TestGC_KeepsActiveAndNewest(t *testing.T) { t.Fatalf("dir %d exists=%v want %v", i, exists, want) } } - if _, err := os.Stat(filepath.Join(root, "ssh", ".tmp-abc")); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(root, "store", "ssh", ".tmp-abc")); !os.IsNotExist(err) { t.Fatal("abandoned temp dirs must be removed") } } diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 25bf942..32586ee 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -15,6 +15,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "path" "path/filepath" @@ -25,7 +26,6 @@ import ( "github.com/compliance-framework/agent/internal/policytree" "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/open-policy-agent/opa/v1/ast" "sigs.k8s.io/yaml" ) @@ -52,25 +52,37 @@ func (p PolicyErrors) Error() string { return strings.Join(parts, "; ") } -// Materialized is one bundle written to disk. +// Layout is where inline bundles live on disk (R67, R82): // -// Layout under root (R67): +// ///policies/... the tree, write-once and content-addressed (Dir) +// / -> // swapped atomically by Activate // -// //bundle/... the tree, write-once and content-addressed (Dir) -// /current -> swapped atomically by Activate -// -// Plugins receive Path, /current/bundle: the same path for every revision of the +// Plugins receive Path, //policies: the same path for every revision of the // bundle, so the policy_file of an unchanged package, and with it the evidence UUID that -// policy-manager seeds with it, survives edits of other files. The symlink is an -// intermediate path component on purpose: OPA's bundle loader does not descend into a -// symlinked root directory, but resolves a symlink earlier in the path like any other. +// policy-manager seeds with it, survives edits of other files. The agent uses the relative +// Links .compliance-framework/policies/inline, next to the OCI policy cache, so an inline +// bundle's path reads like a local source's and does not depend on the state directory +// (R82). The symlink is an intermediate path component on purpose: OPA's bundle loader does +// not descend into a symlinked root directory, but resolves a symlink earlier in the path +// like any other, so policies/ is a real directory inside the tree. +// +// Two agents that share a working directory and use the same bundle name share +// / and would swap it under each other; run one agent per working directory. +type Layout struct { + // Store holds the content-addressed trees (under the agent's state directory). + Store string + // Links holds each bundle's stable symlink. Plugins receive paths under it. + Links string +} + +// Materialized is one bundle written to disk (see Layout). type Materialized struct { Name string - // Dir is the tree itself (///bundle). The checks run on it and its + // Dir is the tree itself (///policies). The checks run on it and its // contents never change. Dir string - // Path is what plugins receive: //current/bundle, or Dir when the file - // system has no symlinks (then evidence identity changes with each revision, as before). + // Path is what plugins receive: //policies, or Dir when the file system has + // no symlinks (then evidence identity changes with each revision, as before R67). Path string Digest string // agentconfig.BundleTreeDigest(files) // Extends describes the vendor tree the bundle extends (nil when standalone). @@ -89,19 +101,24 @@ type Materialized struct { // SetViolations are the authored modules that define violation as a set, which plugins // built on an agent library older than v0.7.1 cannot evaluate; PolicyIDRules are the // authored modules that declare policy_id, which plugins built before R74 ignore (R76). + // Neither counts the policy_id the agent appends (Continued). SetViolations, PolicyIDRules []Site + // Continued maps the modules the agent appended a continuity policy_id to (R82), by + // path, to that policy_id. + Continued map[string]string } // Materialize builds bundle name in the R17 order (extends tree, delete, modules, data), -// checks the data-file rule (R18) and writes the result write-once under root. -func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBundle, resolve Resolver) (*Materialized, error) { +// checks the data-file rule (R18), appends the continuity policy_id to the modules that +// continue a vendor file (R82) and writes the result write-once under l.Store. +func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.PolicyBundle, resolve Resolver) (*Materialized, error) { if b == nil { return nil, PolicyErrors{{Bundle: name, Message: "bundle has no definition", Severity: agentconfig.SeverityError}} } if !agentconfig.BundleNamePattern.MatchString(name) { return nil, PolicyErrors{{Bundle: name, Message: "invalid bundle name", Severity: agentconfig.SeverityError}} } - m := &Materialized{Name: name, Authored: map[string]bool{}} + m := &Materialized{Name: name, Authored: map[string]bool{}, Continued: map[string]string{}} var errs PolicyErrors warn := func(p, format string, args ...any) { m.Warnings = append(m.Warnings, agentconfig.PolicyError{Bundle: name, Path: p, Message: fmt.Sprintf(format, args...), Severity: agentconfig.SeverityWarning}) @@ -112,6 +129,7 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu // 1. Base tree. files := map[string][]byte{} + var vendorFiles map[string][]byte if b.Extends != nil { if resolve == nil { return nil, fmt.Errorf("%w: no resolver", ErrResolve) @@ -142,7 +160,8 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu } m.ExtendsDir = dir m.ExtendsIdentities = Identities(baseFiles) - files = baseFiles + vendorFiles = baseFiles + files = maps.Clone(baseFiles) } // 2. Delete. @@ -208,23 +227,30 @@ func Materialize(ctx context.Context, root, name string, b *agentconfig.PolicyBu return nil, errs } - // 6. Write once. + // The authored constructs, before the agent adds anything. + m.SetViolations, m.PolicyIDRules = authoredSites(files, m.Authored) + + // 6. Continuity policy_id (R82). + if m.Extends != nil { + m.Warnings = append(m.Warnings, continueVendorStreams(m, files, vendorFiles)...) + } + + // 7. Write once. m.Digest = agentconfig.BundleTreeDigest(files) - final := filepath.Join(root, name, strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix)) + final := filepath.Join(l.Store, name, strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix)) if err := writeOnce(final, files); err != nil { return nil, err } m.Dir = filepath.Join(final, treeDir) m.Path = m.Dir - if symlinksSupported(root) { - m.Path = filepath.Join(root, name, currentLink, treeDir) + if symlinksSupported(l.Links) { + m.Path = filepath.Join(l.Links, name, treeDir) } - // 7. Inventory. + // 8. Inventory: the tree as written, so Files matches Digest and the uploaded artifact. m.Files = inventory(files) slices.Sort(m.AuthoredTests) m.Identities = Identities(files) - m.SetViolations, m.PolicyIDRules = authoredSites(files, m.Authored) return m, nil } @@ -298,8 +324,8 @@ func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { sum := sha256.Sum256(files[p]) r := agentconfig.PolicyFileReport{Path: p, SHA256: hex.EncodeToString(sum[:])} if strings.HasSuffix(p, ".rego") { - if mod, err := ast.ParseModuleWithOpts(p, string(files[p]), ast.ParserOptions{RegoVersion: ast.RegoV1}); err == nil && mod != nil && mod.Package != nil { - r.Package = strings.TrimPrefix(mod.Package.Path.String(), "data.") + if mod := parseRego(p, files[p]); mod != nil { + r.Package = packageOf(mod) } } out = append(out, r) @@ -308,20 +334,22 @@ func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { } const ( - // treeDir is the directory holding the policy tree inside a materialized directory. - treeDir = "bundle" - // currentLink is the per-bundle symlink to the active materialized directory. - currentLink = "current" - // treeMarker marks a complete materialized directory in this layout. It sits next to - // treeDir, outside the tree. + // treeDir is the directory holding the policy tree inside a materialized directory, the + // last component of the path plugins receive (like an OCI source's policies/). + treeDir = "policies" + // legacyCurrentLink is the per-bundle symlink of the R67 layout (//current + // -> , plugins got //current/bundle). GC removes it (R82). + legacyCurrentLink = "current" + // treeMarker marks a complete materialized directory. It sits next to treeDir, outside + // the tree. treeMarker = ".ccf-tree" - // Name prefixes of transient entries in a bundle directory. + // Name prefixes of transient entries in a bundle directory, and in Links. tmpPrefix = ".tmp-" currentTmpPrefix = ".current-" symlinkProbeEntry = ".symlink-probe-" ) -// writeOnce writes files under final/bundle unless final is already complete: a temp dir +// writeOnce writes files under final/policies unless final is already complete: a temp dir // (dirs 0755, files 0644) with the completion marker, renamed into place. Losing a rename // race reuses the winner's directory. func writeOnce(final string, files map[string][]byte) error { @@ -329,8 +357,8 @@ func writeOnce(final string, files map[string][]byte) error { return nil } if _, err := os.Lstat(final); err == nil { - // A directory in the layout before R67 (the tree directly under final, which may - // itself contain a bundle/ directory): rebuild it. + // A directory in an earlier layout (the tree directly under final before R67, or + // under final/bundle before R82): rebuild it. if err := os.RemoveAll(final); err != nil { return err } @@ -369,10 +397,15 @@ func writeOnce(final string, files map[string][]byte) error { return nil } -// complete reports whether final is a materialized directory in this layout. +// complete reports whether final is a materialized directory in this layout: the marker and +// a real policies/ directory (an R67 directory has the marker and bundle/). func complete(final string) bool { info, err := os.Stat(filepath.Join(final, treeMarker)) - return err == nil && info.Mode().IsRegular() + if err != nil || !info.Mode().IsRegular() { + return false + } + tree, err := os.Lstat(filepath.Join(final, treeDir)) + return err == nil && tree.IsDir() } func randomSuffix() string { @@ -402,32 +435,39 @@ func symlinksSupported(root string) bool { } // Activate points bundle name's stable path (Materialized.Path) at dir, a Materialized.Dir -// of that bundle under root. The swap is atomic: a temporary symlink renamed over -// /current, so on Linux a reader resolving the stable path sees either the previous +// of that bundle under l.Store. The swap is atomic: a temporary symlink renamed over +// /, so on Linux a reader resolving the stable path sees either the previous // tree or the new one, never neither (macOS APFS may fail such a racing lookup with EINVAL). // It is not a snapshot for a reader walking the tree while it is swapped either. Callers // therefore swap only while no plugin of the previous configuration runs (the agent does it // between two configuration runs, after the reload drain), and serialize Activate with GC. // It is a no-op when the tree is already active or the file system has no symlinks (plugins // then receive dir itself). -func Activate(root, name, dir string) error { - bundleDir := filepath.Join(root, name) +func Activate(l Layout, name, dir string) error { versionDir := filepath.Dir(filepath.Clean(dir)) - if filepath.Base(filepath.Clean(dir)) != treeDir || filepath.Dir(versionDir) != bundleDir { + if filepath.Base(filepath.Clean(dir)) != treeDir || filepath.Dir(versionDir) != filepath.Join(l.Store, name) { return fmt.Errorf("activate %s: %s is not a materialized tree of the bundle", name, dir) } - if !symlinksSupported(root) { + if !symlinksSupported(l.Links) { return nil } - if info, err := os.Stat(dir); err != nil || !info.IsDir() { + if !complete(versionDir) { return fmt.Errorf("activate %s: the materialized tree %s is missing", name, dir) } - target := filepath.Base(versionDir) - link := filepath.Join(bundleDir, currentLink) + // An absolute target: the link lives next to the policy cache and the tree under the + // state directory, which may be anywhere. + target, err := filepath.Abs(versionDir) + if err != nil { + return fmt.Errorf("activate %s: %w", name, err) + } + link := filepath.Join(l.Links, name) if cur, err := os.Readlink(link); err == nil && cur == target { return nil } - tmp := filepath.Join(bundleDir, currentTmpPrefix+randomSuffix()) + if err := os.MkdirAll(l.Links, 0o755); err != nil { + return fmt.Errorf("activate %s: %w", name, err) + } + tmp := filepath.Join(l.Links, tmpPrefix+name+"-"+randomSuffix()) if err := os.Symlink(target, tmp); err != nil { return fmt.Errorf("activate %s: %w", name, err) } @@ -438,32 +478,40 @@ func Activate(root, name, dir string) error { return nil } -// GC removes materialized directories under root except those in keep (Materialized.Dir -// values, or their parent), the one each bundle's current symlink points to, and the -// perBundle newest per bundle, plus abandoned temporary entries. Callers serialize GC with -// Activate. -func GC(root string, keep map[string]struct{}, perBundle int) error { - bundles, err := os.ReadDir(root) +// GC removes materialized directories under l.Store except those in keep (Materialized.Dir +// values, or their parent), the one each bundle's stable link points to, and the perBundle +// newest per bundle, plus abandoned temporary entries, directories of an earlier layout and +// the R67 current links. Callers serialize GC with Activate. +func GC(l Layout, keep map[string]struct{}, perBundle int) error { + var errs []error + // Abandoned temporary links of Activate. + if entries, err := os.ReadDir(l.Links); err == nil { + for _, e := range entries { + if strings.HasPrefix(e.Name(), tmpPrefix) { + errs = append(errs, os.Remove(filepath.Join(l.Links, e.Name()))) + } + } + } + bundles, err := os.ReadDir(l.Store) if errors.Is(err, os.ErrNotExist) { - return nil + return errors.Join(errs...) } if err != nil { return err } - var errs []error for _, b := range bundles { if !b.IsDir() { continue } - bundleDir := filepath.Join(root, b.Name()) + bundleDir := filepath.Join(l.Store, b.Name()) entries, err := os.ReadDir(bundleDir) if err != nil { errs = append(errs, err) continue } active := "" - if target, err := os.Readlink(filepath.Join(bundleDir, currentLink)); err == nil { - active = filepath.Join(bundleDir, target) + if target, err := os.Readlink(filepath.Join(l.Links, b.Name())); err == nil { + active = absPath(target) } type dirInfo struct { path string @@ -476,37 +524,52 @@ func GC(root string, keep map[string]struct{}, perBundle int) error { errs = append(errs, os.RemoveAll(p)) continue } + if e.Name() == legacyCurrentLink && e.Type()&os.ModeSymlink != 0 { + // Plugins no longer receive the R67 path, so nothing resolves it. + errs = append(errs, os.Remove(p)) + continue + } if !e.IsDir() { continue } - info, err := e.Info() - if err != nil { + if absPath(p) == active { continue } - dirs = append(dirs, dirInfo{p, info.ModTime().UnixNano()}) - } - sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod > dirs[j].mod }) - kept := 0 - for _, d := range dirs { - if d.path == active { + if _, ok := keep[p]; ok { continue } - if _, ok := keep[d.path]; ok { + if _, ok := keep[filepath.Join(p, treeDir)]; ok { continue } - if _, ok := keep[filepath.Join(d.path, treeDir)]; ok { + if !complete(p) { + // A tree of an earlier layout: Materialize rebuilds it when it is needed. + errs = append(errs, os.RemoveAll(p)) continue } - if kept < perBundle { - kept++ + info, err := e.Info() + if err != nil { continue } - errs = append(errs, os.RemoveAll(d.path)) + dirs = append(dirs, dirInfo{p, info.ModTime().UnixNano()}) + } + sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod > dirs[j].mod }) + for i, d := range dirs { + if i >= perBundle { + errs = append(errs, os.RemoveAll(d.path)) + } } } return errors.Join(errs...) } +// absPath returns p made absolute and cleaned, or p cleaned when that fails. +func absPath(p string) string { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return filepath.Clean(p) +} + func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { From 01a0ee47f32c3172dbb8896d46fa553803397590 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 1 Oct 2026 16:09:47 -0300 Subject: [PATCH 32/47] wip(shadow): per-plugin path shadowing for inline bundles (prototype) Plugins seed evidence UUIDs from the policy path string they receive. Give a bundle that extends a relative (OCI) source to plugins at the source's own path, and run each such plugin in a per-plugin view directory (/views//, internal/policyview) in which that path's parent links to the bundle's content-addressed tree (the leaf policies/ stays real: OPA loads nothing from a symlinked root) and every other entry of the agent's working directory is mirrored as a symlink. Inherited and overridden modules then keep the vendor streams with any plugin build, without a continuity policy_id; added modules start path-based streams. - inlinepolicy.Materialize gains Options{Shadow}: Path = extends plugin path, no continuity policy_id (Materialized.Shadowed). - cmd/shadow.go decides shadowing before materializing: a bundle is shadowed when its extends path is shadowable and every plugin using it can get a view (it does not also load the source or another bundle on the same path, and its other relative paths fit in a view). Otherwise R82 is the fallback (and R75 reports a source loaded next to its bundle). - Plugins are started by absolute path with cmd.Dir = view; views are content-addressed (no swap under a running plugin), ensured on activation and before each run, and GC'd with the inline trees. - The API helper resolves relative policy paths through the plugin's view (WithPolicyRoot), so evaluation-time artifacts are the bundle's tree; the _policy_source/_policy_digest props fall back to the evidence's _policy_path label for plugins that send no policy evaluations. - R79 gate relaxed: only unshadowed extends bundles (continuity through policy_id) need agent >= v0.9.0; set-form violations still need >= v0.7.1; an ignored authored policy_id warns. inline-policies is supported for every known library version, unknown otherwise. Co-Authored-By: Claude Opus 5.5 --- cmd/agent.go | 46 ++- cmd/compat.go | 113 +++--- cmd/compat_test.go | 37 +- cmd/inline.go | 32 +- cmd/inline_test.go | 47 +-- cmd/reconciler.go | 4 + cmd/shadow.go | 236 +++++++++++++ cmd/shadow_test.go | 424 +++++++++++++++++++++++ docs/adr/0003-remote-config-overlay.md | 12 + docs/configuration.md | 57 +-- internal/inlinepolicy/continuity_test.go | 26 ++ internal/inlinepolicy/materialize.go | 33 +- internal/policyview/policyview.go | 319 +++++++++++++++++ internal/policyview/policyview_test.go | 124 +++++++ runner/result.go | 50 ++- runner/shadow_test.go | 92 +++++ 16 files changed, 1506 insertions(+), 146 deletions(-) create mode 100644 cmd/shadow.go create mode 100644 cmd/shadow_test.go create mode 100644 internal/policyview/policyview.go create mode 100644 internal/policyview/policyview_test.go create mode 100644 runner/shadow_test.go diff --git a/cmd/agent.go b/cmd/agent.go index 5b53ed7..b034518 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -32,6 +32,7 @@ import ( "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/internal/policyview" "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" @@ -103,6 +104,10 @@ type agentConfig struct { // inlineDigests maps "inline:" policy entries to their tree digest, the evidence // _policy_digest fallback when a bundle's artifact digest is not known. inlineDigests map[string]string + // pluginViews are the working directories of the plugins that receive a shadowed inline + // bundle (path shadowing): the plugin process runs in its view, where the bundle's + // plugin path (the extends source's own path) resolves to the bundle's tree. + pluginViews map[string]*policyview.View // 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] @@ -1434,7 +1439,15 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { return err } - runnerInstance, cleanupRunner, err := ar.getRunnerInstance(logger, source, pluginConfig.ProtocolVersion) + workDir, err := config.pluginWorkDir(pluginName) + if err != nil { + ar.markPluginRunFinished(pluginName, err) + if evidenceErr := ar.sendAgentRunEvidenceAfterCompleteRun(ctx); evidenceErr != nil { + logger.Error("Error sending agent run evidence", "error", evidenceErr) + } + return err + } + runnerInstance, cleanupRunner, err := ar.getRunnerInstance(logger, source, pluginConfig.ProtocolVersion, workDir) if err != nil { ar.markPluginRunFinished(pluginName, err) @@ -1478,6 +1491,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { ) resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths), + runner.WithPolicyRoot(workDir), runner.WithSources(sourceOf(pluginConfig.Source, source), policySources), runner.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...), @@ -1610,7 +1624,11 @@ func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name return err } - runnerInstance, cleanupRunner, err := ar.getRunnerInstance(pluginLogger, pluginExecutable, plugin.ProtocolVersion) + workDir, err := config.pluginWorkDir(name) + if err != nil { + return err + } + runnerInstance, cleanupRunner, err := ar.getRunnerInstance(pluginLogger, pluginExecutable, plugin.ProtocolVersion, workDir) if err != nil { return err @@ -1629,6 +1647,7 @@ func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name ) resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), + runner.WithPolicyRoot(workDir), runner.WithSources(sourceOf(plugin.Source, pluginExecutable), policySources), runner.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...), @@ -1936,9 +1955,15 @@ func safePluginErrorFilename(pluginName string) string { return b.String() + "-error.txt" } -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. +func (ar *AgentRunner) getRunnerInstance(logger hclog.Logger, path string, protocolVersion int32, workDir string) (runner.RunnerV2, func(), error) { + // We're a host! Start by launching the plugin process. The binary is started by its + // absolute path: a relative one would resolve against workDir. + if abs, err := filepath.Abs(path); err == nil { + path = abs + } cmd := exec.Command(path) + // A plugin that receives a shadowed inline bundle runs in its view (path shadowing). + cmd.Dir = workDir // Plugins get the host environment minus the agent's own API credentials (R26); go-plugin // would otherwise append the whole environment. cmd.Env = pluginEnviron(os.Environ()) @@ -2233,6 +2258,19 @@ func sourceOf(source, location string) runner.Source { return runner.Source{Reference: source, Digest: internal.SourceDigest(source, location)} } +// pluginWorkDir returns the working directory plugin runs in: its view, made sure to exist +// and refreshed (path shadowing), or "" for the agent's own working directory. +func (c *agentConfig) pluginWorkDir(plugin string) (string, error) { + view := c.pluginViews[plugin] + if view == nil { + return "", nil + } + if err := view.Ensure(); err != nil { + return "", fmt.Errorf("plugin %s: %w", plugin, err) + } + return view.Dir, nil +} + // policySource describes where the policy entry, which plugins receive at location, came from. // An inline bundle is recorded as its entry (inline:) with its artifact digest, or its // tree digest when the evaluation could not store the bundle; location is then the bundle's diff --git a/cmd/compat.go b/cmd/compat.go index 5ea413b..7673937 100644 --- a/cmd/compat.go +++ b/cmd/compat.go @@ -9,21 +9,28 @@ import ( "github.com/compliance-framework/api/pkg/agentconfig" ) -// Plugin compatibility (R76, R79). A plugin evaluates policies with the policy-manager it -// embeds, so what it can do with an inline bundle depends on the agent library it was built -// with, which the agent reads from the plugin binary's build info: +// Plugin compatibility (R76, R79, relaxed by path shadowing). A plugin evaluates policies +// with the policy-manager it embeds, so what it can do with an inline bundle depends on the +// agent library it was built with, which the agent reads from the plugin binary's build info. +// With path shadowing (shadow.go) a plugin keeps the vendor's evidence streams of a bundle +// that extends a relative (OCI) source whatever library it was built with, so the R79 gate +// only remains where continuity cannot be guaranteed otherwise: // -// - inline policies need pluginlib.MinInlinePolicy, the first library that seeds evidence -// with policy_id (R74). An overlay that gives a plugin built on an older library an -// inline bundle, changes one it uses, or moves a plugin that uses one to such a build -// is rejected (plugin-lib-inline-unsupported), so the last good configuration keeps -// running (R79); +// - a bundle that extends a source and is not shadowed (an absolute local extends path, +// a plugin that also loads the source, no symlinks) keeps the vendor streams only +// through the continuity policy_id (R82), which needs pluginlib.MinInlinePolicy (R74). +// For an older library an overlay that introduces it is rejected +// (plugin-lib-inline-unsupported), so the last good configuration keeps running; // - a set-form violation (`violation contains ...`) crashes plugins older than -// pluginlib.MinViolationSet, which is named separately when it applies, with the fix; +// pluginlib.MinViolationSet: rejected when the overlay introduces it +// (plugin-lib-violation-set-unsupported); +// - a policy_id an authored module declares is ignored by an older library, so the +// module's stream is path-based: a warning (plugin-lib-policy-id-unsupported); // - inline bundles from the config file only warn (R34), and so does a library whose -// version is unknown (a local or replaced build, or no build info), so local plugin -// builds keep working. For file bundles a policy_id that the plugin ignores is named per -// module (plugin-lib-policy-id-unsupported); for overlay bundles the gate supersedes it. +// version is unknown (a local or replaced build, or no build info). +// +// plugins[].inline-policies now reads: supported (the library version is known, so the agent +// checks each bundle against it as above) or unknown; unsupported is no longer reported. // Shorter names for the plugins[].inline-policies values (R79). const ( @@ -36,17 +43,13 @@ const ( // with ("" when unknown). The source has been prefetched. type pluginLibFunc func(ctx context.Context, source string) (string, error) -// inlineSupport classifies a plugin's agent library version for inline policies. +// inlineSupport classifies a plugin's agent library version for inline policies: with path +// shadowing every known library takes inline bundles (each bundle is checked against it). func inlineSupport(version string) string { - ok, known := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy) - switch { - case !known: + if _, known := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy); !known { return inlinePoliciesUnknown - case ok: - return inlinePoliciesSupported - default: - return inlinePoliciesUnsupported } + return inlinePoliciesSupported } // pluginCompatibility returns the R76/R79 problems of the plugins of runtime that use inline @@ -69,8 +72,8 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon } support := inlineSupport(version) reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version, InlinePolicies: support}) - if support == inlinePoliciesSupported { - continue + if ok, _ := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy); ok { + continue // policy_id and set-form violations both work } var bundles []*inlinepolicy.Materialized @@ -89,53 +92,65 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon if len(bundles) == 0 { continue } - problems = append(problems, libProblems(name, version, support, bundles, introduced)...) + problems = append(problems, libProblems(name, version, bundles, introduced)...) } return problems, reports } -// libProblems are the problems of one plugin whose library is not known to support inline -// policies (support is unsupported or unknown). -func libProblems(plugin, version, support string, bundles []*inlinepolicy.Materialized, overlay bool) []agentconfig.PolicyError { +// needsPolicyID reports whether bundle m keeps vendor evidence streams only through the +// continuity policy_id the agent appended (R82): it extends a source and is not shadowed. +func needsPolicyID(m *inlinepolicy.Materialized) bool { + return m.Extends != nil && !m.Shadowed && len(m.Continued) > 0 +} + +// libProblems are the problems of one plugin whose library is older than +// pluginlib.MinInlinePolicy or unknown. overlay is whether the overlay brought the plugin and +// these bundles together; only then is a known incompatibility an error. +func libProblems(plugin, version string, bundles []*inlinepolicy.Materialized, overlay bool) []agentconfig.PolicyError { var out []agentconfig.PolicyError lib := version if lib == "" { lib = "unknown" } - severity := agentconfig.SeverityWarning - if support == inlinePoliciesUnsupported && overlay { - severity = agentconfig.SeverityError - } + idOK, known := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy) setOK, setKnown := pluginlib.AtLeast(version, pluginlib.MinViolationSet) + severity := func(known bool) string { + if known && overlay { + return agentconfig.SeverityError + } + return agentconfig.SeverityWarning + } + unknownWhy := "is unknown (a local or replaced build, or no build info)" + if version != "" { + unknownWhy = fmt.Sprintf("%s has no release before it", version) + } for _, m := range bundles { - msg := fmt.Sprintf("plugin %s (agent lib %s) doesn't support inline policies; upgrade the plugin to a build on agent ≥ %s", plugin, lib, pluginlib.MinInlinePolicy) - if support == inlinePoliciesUnknown { - why := "is unknown (a local or replaced build, or no build info)" - if version != "" { - why = fmt.Sprintf("%s has no release before it", version) + if !idOK && needsPolicyID(m) { + msg := fmt.Sprintf("plugin %s (agent lib %s) cannot keep the vendor evidence streams of bundle %s: it extends %s at %s, which cannot be shadowed, so its inherited and overridden modules continue the vendor streams only through policy_id, which needs agent ≥ %s; upgrade the plugin, or extend a relative (OCI) source the plugin does not also load", + plugin, lib, m.Name, m.Extends.Source, m.Extends.PluginPath, pluginlib.MinInlinePolicy) + if !known { + msg = fmt.Sprintf("plugin %s: its agent library version %s, so the agent cannot tell whether it honours the policy_id that continues the vendor streams of bundle %s (it extends %s at %s, which cannot be shadowed); plugins built on agent < %s ignore it and start new streams", + plugin, unknownWhy, m.Name, m.Extends.Source, m.Extends.PluginPath, pluginlib.MinInlinePolicy) } - msg = fmt.Sprintf("plugin %s: its agent library version %s, so the agent cannot tell whether it supports inline policies; plugins built on agent < %s ignore policy_id, and those < %s crash on `violation contains ...`", - plugin, why, pluginlib.MinInlinePolicy, pluginlib.MinViolationSet) + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Severity: severity(known), Code: agentconfig.PolicyCodePluginLibInlineUnsupported, Message: msg}) } - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Severity: severity, Code: agentconfig.PolicyCodePluginLibInlineUnsupported, Message: msg}) - if !setOK { - sev := severity - if !setKnown { - sev = agentconfig.SeverityWarning - } for _, s := range m.SetViolations { - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: sev, - Code: agentconfig.PolicyCodePluginLibViolationSetUnsupported, - Message: fmt.Sprintf("plugin %s (agent lib %s) cannot evaluate violation as a set (`violation contains ...` needs agent ≥ %s) and would crash; use `violation[{...}] if { … }`", - plugin, lib, pluginlib.MinViolationSet)}) + msg := fmt.Sprintf("plugin %s (agent lib %s) cannot evaluate violation as a set (`violation contains ...` needs agent ≥ %s) and would crash; use `violation[{...}] if { … }`", + plugin, lib, pluginlib.MinViolationSet) + if !setKnown { + msg = fmt.Sprintf("plugin %s: its agent library version %s; plugins built on agent < %s crash on `violation contains ...`; use `violation[{...}] if { … }`", + plugin, unknownWhy, pluginlib.MinViolationSet) + } + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: severity(setKnown), + Code: agentconfig.PolicyCodePluginLibViolationSetUnsupported, Message: msg}) } } - if support == inlinePoliciesUnsupported && !overlay { + if !idOK && known { for _, s := range m.PolicyIDRules { out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: agentconfig.SeverityWarning, Code: agentconfig.PolicyCodePluginLibPolicyIDUnsupported, - Message: fmt.Sprintf("plugin %s (agent lib %s) ignores policy_id; this module starts a new evidence stream", plugin, lib)}) + Message: fmt.Sprintf("plugin %s (agent lib %s) ignores policy_id; this module's evidence stream follows its path", plugin, lib)}) } } } diff --git a/cmd/compat_test.go b/cmd/compat_test.go index d7e8bd0..ab0a79a 100644 --- a/cmd/compat_test.go +++ b/cmd/compat_test.go @@ -10,8 +10,10 @@ import ( "github.com/compliance-framework/api/pkg/agentconfig" ) -// Plugin compatibility (R76, R79): the plugin's agent library decides whether it may use -// inline policies. +// Plugin compatibility (R76, R79, relaxed by path shadowing): the plugin's agent library +// decides what it can do with an inline bundle. The harness's vendor is an absolute path, +// which cannot be shadowed, so these bundles need policy_id (shadow_test.go covers +// shadowed bundles). // withPluginLib makes the harness's plugins report version as their agent library. func withPluginLib(h *remoteHarness, version string) { @@ -58,7 +60,7 @@ func TestCompat_OldLibRejectsOverlayInlineBundle_R79(t *testing.T) { } gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported) if len(gate) != 1 || gate[0].Bundle != "ssh" || - !strings.Contains(gate[0].Message, "plugin ssh (agent lib v0.1.9-0.20250708121809-c5059c3efac8) doesn't support inline policies") || + !strings.Contains(gate[0].Message, "plugin ssh (agent lib v0.1.9-0.20250708121809-c5059c3efac8) cannot keep the vendor evidence streams of bundle ssh") || !strings.Contains(gate[0].Message, pluginlib.MinInlinePolicy) { t.Fatalf("expected one plugin-lib-inline-unsupported error naming the plugin, its lib and the minimum, got %+v", r.PolicyErrors) } @@ -68,12 +70,13 @@ func TestCompat_OldLibRejectsOverlayInlineBundle_R79(t *testing.T) { !strings.Contains(set[0].Message, "violation[{...}] if") { t.Fatalf("expected a located set-form violation error, got %+v", r.PolicyErrors) } - // The gate supersedes the policy_id warning for overlay bundles. + // No authored policy_id, so no policy_id warning. if got := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibPolicyIDUnsupported); len(got) != 0 { - t.Fatalf("no policy_id warning expected next to the gate, got %+v", got) + t.Fatalf("no policy_id warning expected, got %+v", got) } + // With path shadowing a known library is reported as supported; the gate is per bundle. if len(r.Plugins) != 1 || r.Plugins[0].Name != "ssh" || r.Plugins[0].Source != "ghcr.io/compliance-framework/plugin-ssh:v1" || - r.Plugins[0].LibVersion != "v0.1.9-0.20250708121809-c5059c3efac8" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesUnsupported { + r.Plugins[0].LibVersion != "v0.1.9-0.20250708121809-c5059c3efac8" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { t.Fatalf("plugins report = %+v", r.Plugins) } } @@ -161,8 +164,8 @@ func TestCompat_FileInlineBundleOnOldLibWarns_R79(t *testing.T) { } func TestLibProblemsNameAnUntaggedVersion(t *testing.T) { - m := &inlinepolicy.Materialized{Name: "ssh"} - got := libProblems("ssh", "v0.0.0-20261001110117-f88bde9ee37a", inlinePoliciesUnknown, []*inlinepolicy.Materialized{m}, true) + m := &inlinepolicy.Materialized{Name: "ssh", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/v", PluginPath: "/v"}, Continued: map[string]string{"x.rego": "/v/x.rego"}} + got := libProblems("ssh", "v0.0.0-20261001110117-f88bde9ee37a", []*inlinepolicy.Materialized{m}, true) if len(got) != 1 || got[0].Severity != agentconfig.SeverityWarning || !strings.Contains(got[0].Message, "v0.0.0-20261001110117-f88bde9ee37a has no release before it") { t.Fatalf("an untagged build only warns and names its version, got %+v", got) } @@ -173,14 +176,16 @@ func TestInlineSupport(t *testing.T) { "v0.9.0": inlinePoliciesSupported, "v0.10.0": inlinePoliciesSupported, "v0.9.1-0.20261001000000-abcdefabcdef": inlinePoliciesSupported, - "v0.9.0-rc1": inlinePoliciesUnsupported, // semver: before v0.9.0 - "v0.8.1": inlinePoliciesUnsupported, // released without R74 (R81) - "v0.8.0": inlinePoliciesUnsupported, // released without R74 (R81) - "v0.8.0-rc4": inlinePoliciesUnsupported, - "v0.7.1": inlinePoliciesUnsupported, - "": inlinePoliciesUnknown, - "(devel)": inlinePoliciesUnknown, - "v0.0.0-20261001110117-f88bde9ee37a": inlinePoliciesUnknown, + // Path shadowing: every known library takes inline bundles (checked per bundle). + "v0.9.0-rc1": inlinePoliciesSupported, + "v0.8.1": inlinePoliciesSupported, + "v0.8.0": inlinePoliciesSupported, + "v0.8.0-rc4": inlinePoliciesSupported, + "v0.7.1": inlinePoliciesSupported, + oldLib: inlinePoliciesSupported, + "": inlinePoliciesUnknown, + "(devel)": inlinePoliciesUnknown, + "v0.0.0-20261001110117-f88bde9ee37a": inlinePoliciesUnknown, } { if got := inlineSupport(version); got != want { t.Errorf("inlineSupport(%q) = %s, want %s", version, got, want) diff --git a/cmd/inline.go b/cmd/inline.go index ec95dbc..33cb90b 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/agent/internal/policyview" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/pkg/agentconfig/regocheck" ) @@ -67,10 +68,15 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co return res, nil } + // Path shadowing is decided before materializing: a shadowed bundle's tree has no + // continuity policy_id. + plan := rc.planShadowing(ctx, resolved, skip, refs) + materialized := map[string]*inlinepolicy.Materialized{} var problems []agentconfig.PolicyError for _, name := range sortedBoolKeys(refs) { - m, err := inlinepolicy.Materialize(ctx, rc.inlineLayout(), name, resolved.PolicyBundles[name], rc.boundedResolver()) + m, err := inlinepolicy.Materialize(ctx, rc.inlineLayout(), name, resolved.PolicyBundles[name], rc.boundedResolver(), + inlinepolicy.Options{Shadow: plan.shadow[name]}) var perrs inlinepolicy.PolicyErrors switch { case errors.As(err, &perrs): @@ -125,6 +131,12 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co res.warnings = dedupePolicyErrors(res.warnings) agentconfig.SortPolicyErrors(res.warnings) + views, err := rc.buildViews(plan, materialized) + if err != nil { + return res, failed(agentconfig.ReasonInternal, err) + } + res.views = views + res.materialized = materialized res.dirs = map[string]string{} res.trees = map[string]string{} @@ -273,6 +285,7 @@ func (rc *reconciler) gcInline(keep ...*candidate) { rc.inlineMu.Lock() defer rc.inlineMu.Unlock() dirs := map[string]struct{}{} + views := map[string]struct{}{} found := false for _, c := range keep { if c == nil || c.runtime == nil { @@ -282,6 +295,9 @@ func (rc *reconciler) gcInline(keep ...*candidate) { for _, dir := range c.runtime.inlineTrees { dirs[dir] = struct{}{} } + for _, v := range c.runtime.pluginViews { + views[v.Dir] = struct{}{} + } } if !found { return @@ -289,9 +305,18 @@ func (rc *reconciler) gcInline(keep ...*candidate) { if err := inlinepolicy.GC(rc.inlineLayout(), dirs, inlineGCKeepPerBundle); err != nil { rc.logger.Warn("Could not clean up old inline policy bundles", "error", err) } + // Views of no kept candidate: they are cheap to rebuild, and a plugin of an earlier + // configuration no longer runs (GC runs after the swap's drain). + if root, err := filepath.Abs(rc.viewsRoot()); err == nil { + if err := policyview.GC(root, views); err != nil { + rc.logger.Warn("Could not clean up old plugin views", "error", err) + } + } } -// activateInline points the stable path of each inline bundle c uses at c's tree (R67). +// activateInline points the stable path of each inline bundle c uses at c's tree (R67), +// then creates the views of the plugins that receive a shadowed bundle. Views are +// content-addressed, so a new tree is a new view and nothing is swapped under a plugin. func (rc *reconciler) activateInline(c *candidate) error { if c == nil || c.runtime == nil || len(c.runtime.inlineTrees) == 0 { return nil @@ -303,6 +328,9 @@ func (rc *reconciler) activateInline(c *candidate) error { name := strings.TrimPrefix(entry, agentconfig.InlineSourcePrefix) errs = append(errs, inlinepolicy.Activate(rc.inlineLayout(), name, c.runtime.inlineTrees[entry])) } + for _, plugin := range sortedMapKeys(c.runtime.pluginViews) { + errs = append(errs, c.runtime.pluginViews[plugin].Ensure()) + } return errors.Join(errs...) } diff --git a/cmd/inline_test.go b/cmd/inline_test.go index 1bb1845..c1c2500 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -594,47 +594,6 @@ func TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75(t *testing.T) { } } -// TestInline_RelativePathContinuesVendorStreams_R82: plugins receive an inline bundle at -// the relative .compliance-framework/policies/inline//policies, like a local source, -// and an inherited vendor module keeps the vendor's evidence stream with no policy_id -// written by the user, next to an added module. -func TestInline_RelativePathContinuesVendorStreams_R82(t *testing.T) { - const source = "ghcr.io/vendor/policies:v1" - const extracted = ".compliance-framework/policies/vendor/policies/v1/policies" - t.Chdir(t.TempDir()) - if err := os.MkdirAll(extracted, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(extracted, "banner.rego"), []byte(r78Vendor), 0o644); err != nil { - t.Fatal(err) - } - h := r78Harness(t, source, false, func(_ context.Context, s string) (string, error) { - if s == source { - return extracted, nil - } - return "", errors.New("unknown source " + s) - }) - h.rc.inlineLinks = "" // the agent's default, relative to the working directory - active := mustStartup(t, h.rc) - if err := h.rc.activateInline(active); err != nil { - t.Fatal(err) - } - - const want = ".compliance-framework/policies/inline/ssh/policies" - path := active.runtime.inlinePolicyDirs["inline:ssh"] - if filepath.ToSlash(path) != want { - t.Fatalf("plugins receive %q, want %q", path, want) - } - inline, _ := r78Report(t, h) - if inline.PluginPath != path { - t.Fatalf("plugin-path = %q, want %q", inline.PluginPath, path) - } - for _, code := range []string{inlinepolicy.CodePolicyStreamForked, inlinepolicy.CodeContinuityPolicyIDSkipped, agentconfig.PolicyCodeDuplicatePolicyIdentity} { - if got := policyErrorsWithCode(h.remote.lastReport(t), code); len(got) != 0 { - t.Fatalf("unexpected %s: %+v", code, got) - } - } - if got, want := r78Evidence(t, path), r78Evidence(t, extracted); got != want { - t.Fatalf("inherited banner evidence UUID = %s, want the vendor's %s", got, want) - } -} +// TestInline_RelativePathContinuesVendorStreams_R82 moved to shadow_test.go: a bundle that +// extends a relative source is now shadowed (TestShadow_PluginReceivesTheVendorPathInItsView); +// the R82 fallback is TestShadow_PluginAlsoLoadingTheSourceFallsBack. diff --git a/cmd/reconciler.go b/cmd/reconciler.go index eff4fed..89c750c 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -17,6 +17,7 @@ import ( "github.com/compliance-framework/agent/internal/agentstate" "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/agent/internal/policyview" runnerpkg "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" @@ -606,6 +607,7 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent } runtime.inlineTrees = inline.trees runtime.inlineDigests = inline.digests + runtime.pluginViews = inline.views prefetchCtx, cancelPrefetch := context.WithTimeout(ctx, prepareNetworkTimeout) err = rc.runner.Prefetch(prefetchCtx, runtime) cancelPrefetch() @@ -661,6 +663,8 @@ type inlineResult struct { warnings []agentconfig.PolicyError // materialized are the bundles the enabled plugins use, by name. materialized map[string]*inlinepolicy.Materialized + // views are the working directories of the plugins that receive a shadowed bundle. + views map[string]*policyview.View } // overlayTouched returns the pointers an overlay changed, computed on the unresolved forms so diff --git a/cmd/shadow.go b/cmd/shadow.go new file mode 100644 index 0000000..32482e5 --- /dev/null +++ b/cmd/shadow.go @@ -0,0 +1,236 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/agent/internal/policyview" + "github.com/compliance-framework/api/pkg/agentconfig" +) + +// Path shadowing (prototype). A plugin keeps an evidence stream exactly when it receives the +// same policy path string (policy-manager seeds evidence UUIDs from it). So an inline bundle +// that extends a source is given to plugins at the source's own plugin path, and the plugin +// runs in a per-plugin view (internal/policyview) in which that path resolves to the +// bundle's tree. Inherited and overridden modules then keep the vendor streams with any +// plugin build, and no continuity policy_id is needed (R82's injection remains the fallback). +// +// A bundle is shadowed when its extends source's plugin path is shadowable (relative, ending +// in policies/: every OCI source) and every enabled plugin that uses it can be given a view: +// the plugin does not also load the source itself or another bundle shadowing the same path, +// and every other relative path it receives can still be resolved in the view. Otherwise the +// bundle falls back to R82 (relative inline path plus continuity policy_id), and loading it +// next to its source is reported by the R75 identity checks as before. + +// shadowPlan is what prepareInline decided about shadowing, with the policy paths each +// plugin receives for its non-inline entries (resolved once, reused for the views). +type shadowPlan struct { + shadow map[string]bool // bundle name -> shadowed + // plugins are the enabled, not skipped plugins that use an inline bundle. + plugins map[string]shadowPlugin + // reasons say why a candidate bundle was not shadowed (logged once). + reasons map[string]string +} + +type shadowPlugin struct { + bundles []string // inline bundles it uses, in order, deduplicated + others []string // the paths it receives for every non-inline entry + sources map[string]bool +} + +// viewsRoot is where the plugin views live, under the state directory. +func (rc *reconciler) viewsRoot() string { + return filepath.Join(rc.store.Dir(), "views") +} + +// planShadowing decides which of the bundles in refs are shadowed. +func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Config, skip map[string]string, refs map[string]bool) shadowPlan { + plan := shadowPlan{shadow: map[string]bool{}, plugins: map[string]shadowPlugin{}, reasons: map[string]string{}} + resolve := rc.boundedResolver() + if resolve == nil || rc.store == nil || !rc.store.Writable() || !inlinepolicy.SymlinksSupported(rc.viewsRoot()) { + return plan + } + extendsPath := map[string]string{} + for _, name := range sortedBoolKeys(refs) { + b := resolved.PolicyBundles[name] + if b == nil || b.Extends == nil { + continue + } + dir, err := resolve(ctx, *b.Extends) + if err != nil { + continue // Materialize reports it + } + if err := policyview.Shadowable(dir); err != nil { + plan.reasons[name] = err.Error() + continue + } + extendsPath[name] = dir + plan.shadow[name] = true + } + + for _, pluginName := range sortedPluginNames(resolved.Plugins) { + p := resolved.Plugins[pluginName] + if p == nil || !p.IsEnabled() { + continue + } + if _, skipped := skip[pluginName]; skipped { + continue + } + sp := shadowPlugin{sources: map[string]bool{}} + seen := map[string]bool{} + for _, e := range p.Policies { + entry := string(e) + if seen[entry] { + continue + } + seen[entry] = true + if name, ok := agentconfig.InlineBundleName(e); ok { + sp.bundles = append(sp.bundles, name) + continue + } + dir, err := resolve(ctx, entry) + if err != nil { + continue // prefetch reports it + } + sp.others = append(sp.others, dir) + sp.sources[filepath.Clean(dir)] = true + } + if len(sp.bundles) > 0 { + plan.plugins[pluginName] = sp + } + } + if len(plan.shadow) == 0 { + return plan + } + + // Drop candidates until every plugin can be given a view. + drop := func(name, why string) bool { + if !plan.shadow[name] { + return false + } + delete(plan.shadow, name) + plan.reasons[name] = why + return true + } + for changed := true; changed; { + changed = false + for _, pluginName := range sortedMapKeys(plan.plugins) { + sp := plan.plugins[pluginName] + var shadowed, others []string + byPath := map[string][]string{} + for _, name := range sp.bundles { + if !plan.shadow[name] { + others = appendPath(others, rc.fallbackInlinePath(name)) + continue + } + path := extendsPath[name] + if sp.sources[filepath.Clean(path)] { + changed = drop(name, fmt.Sprintf("plugin %s also loads %s (%s) itself", pluginName, *resolved.PolicyBundles[name].Extends, path)) || changed + others = appendPath(others, rc.fallbackInlinePath(name)) + continue + } + byPath[filepath.Clean(path)] = append(byPath[filepath.Clean(path)], name) + shadowed = append(shadowed, path) + } + for _, names := range byPath { + if len(names) > 1 { + for _, name := range names { + changed = drop(name, fmt.Sprintf("plugin %s uses more than one bundle that extends %s", pluginName, extendsPath[name])) || changed + } + } + } + if changed { + break // recompute with the new decisions + } + if _, err := policyview.Plan(shadowed, append(others, sp.others...)); err != nil { + for _, name := range sp.bundles { + changed = drop(name, fmt.Sprintf("plugin %s cannot be given a view: %v", pluginName, err)) || changed + } + if changed { + break + } + } + } + } + for _, name := range sortedMapKeys(plan.reasons) { + if rc.logOnce("shadow\x00" + name + "\x00" + plan.reasons[name]) { + rc.logger.Info("Inline bundle is not shadowed; plugins receive it at its own path, with continuity policy_ids", "bundle", name, "reason", plan.reasons[name]) + } + } + return plan +} + +// fallbackInlinePath is the path plugins receive for an inline bundle that is not shadowed +// (R82), or "" (an absolute tree directory, unaffected by views) without symlinks. +func (rc *reconciler) fallbackInlinePath(name string) string { + l := rc.inlineLayout() + if !inlinepolicy.SymlinksSupported(l.Links) { + return "" + } + return filepath.Join(l.Links, name, policyview.TreeDir) +} + +func appendPath(paths []string, p string) []string { + if p == "" { + return paths + } + return append(paths, p) +} + +// buildViews returns the view of every plugin that receives a shadowed bundle. +func (rc *reconciler) buildViews(plan shadowPlan, materialized map[string]*inlinepolicy.Materialized) (map[string]*policyview.View, error) { + var views map[string]*policyview.View + base, err := os.Getwd() + if err != nil { + return nil, err + } + root, err := filepath.Abs(rc.viewsRoot()) + if err != nil { + return nil, err + } + for _, pluginName := range sortedMapKeys(plan.plugins) { + sp := plan.plugins[pluginName] + var shadowed, others []string + targets := map[string]string{} // plugin path -> the directory holding the tree + for _, name := range sp.bundles { + m := materialized[name] + if m == nil { + continue + } + if !m.Shadowed { + others = append(others, m.Path) + continue + } + dir, err := filepath.Abs(filepath.Dir(m.Dir)) + if err != nil { + return nil, err + } + shadowed = append(shadowed, m.Path) + targets[m.Path] = dir + } + if len(shadowed) == 0 { + continue + } + links, err := policyview.Plan(shadowed, append(others, sp.others...)) + if err != nil { + // planShadowing checked the same paths. + return nil, fmt.Errorf("plugin %s: %w", pluginName, err) + } + viewLinks := map[string]string{} + for path, link := range links { + viewLinks[link] = targets[path] + } + if views == nil { + views = map[string]*policyview.View{} + } + views[pluginName] = &policyview.View{ + Dir: policyview.DirFor(root, pluginName, base, viewLinks), + Base: base, + Links: viewLinks, + } + } + return views, nil +} diff --git a/cmd/shadow_test.go b/cmd/shadow_test.go new file mode 100644 index 0000000..f20bcb9 --- /dev/null +++ b/cmd/shadow_test.go @@ -0,0 +1,424 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/agent/internal/pluginlib" + policy_manager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/hashicorp/go-hclog" +) + +// Path shadowing: a plugin receives an inline bundle that extends a relative (OCI) source at +// the source's own path, and runs in a view where that path resolves to the bundle's tree. + +const ( + shadowSource = "ghcr.io/vendor/policies:v1" + // Where the agent extracts the OCI source: relative to its working directory. + shadowExtracted = ".compliance-framework/policies/vendor/policies/v1/policies" + // A plugin built on agent v0.1.9 (plugin-local-ssh v0.2.0): no policy_id, no set-form + // violations. + oldLib = "v0.1.9-0.20250708121809-c5059c3efac8" +) + +var shadowVendor = map[string]string{ + "banner.rego": "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation[{\"id\": \"b\"}] if not input.banner\n", + "keys.rego": "package compliance_framework.keys\n\nimport rego.v1\n\ntitle := \"Keys\"\n\nviolation[{\"id\": \"k\"}] if input.password\n", + "keys_test.rego": "package compliance_framework.keys_test\n\nimport rego.v1\n\ntest_ok if true\n", + "lib/helpers.rego": "package ccf_libs.helpers\n\nimport rego.v1\n\nyes := true\n", +} + +const shadowConfig = ` +daemon: true +api: + url: http://api.test + auth: + client_id: 123e4567-e89b-12d3-a456-426614174000 + client_secret: s3cret +remote_config: + mode: apply_safe +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-ssh:v1 + policies: ["inline:ssh"] +policy_bundles: + ssh: + extends: ghcr.io/vendor/policies:v1 + modules: + keys.rego: | + package compliance_framework.keys + + import rego.v1 + + title := "Keys (tuned)" + + violation[{"id": "k2"}] if input.password + added.rego: | + package compliance_framework.added + + import rego.v1 + + title := "Added" + + violation[{"id": "a"}] if input.password +` + +// shadowHarness changes to a new working directory holding the extracted vendor source and +// returns a harness on config whose policy sources resolve like the agent's (relative paths). +func shadowHarness(t *testing.T, config string) *remoteHarness { + t.Helper() + t.Chdir(t.TempDir()) + skipWithoutSymlinksHere(t) + for p, src := range shadowVendor { + dst := filepath.Join(shadowExtracted, filepath.FromSlash(p)) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + } + h := newRemoteHarness(t, config) + h.rc.inlineLinks = "" // the agent's default, relative to the working directory + h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { + if source == shadowSource { + return shadowExtracted, nil + } + return "", errors.New("unknown source " + source) + } + return h +} + +func skipWithoutSymlinksHere(t *testing.T) { + t.Helper() + if err := os.Symlink(".", "probe"); err != nil { + t.Skip("symlinks are not supported here") + } + _ = os.Remove("probe") +} + +type shadowEvidence struct { + uuid, title string + labels map[string]string +} + +// evaluateIn evaluates policyPath with the working directory dir, the way the ssh plugin +// does (labels with the literal _policy_path), and returns the evidence by package. +func evaluateIn(t *testing.T, dir, policyPath string) map[string]shadowEvidence { + t.Helper() + back, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(back) }() + labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-1", "_policy_path": policyPath} + evidence, err := policy_manager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). + GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) + if err != nil { + t.Fatal(err) + } + out := map[string]shadowEvidence{} + for _, e := range evidence { + out[e.Labels["_policy"]] = shadowEvidence{uuid: e.UUID, title: e.Title, labels: e.Labels} + } + return out +} + +func TestShadow_PluginReceivesTheVendorPathInItsView(t *testing.T) { + h := shadowHarness(t, shadowConfig) + withPluginLib(h, oldLib) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable { + t.Fatalf("expected the file bundle to load, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + } + + // The plugin receives the vendor's exact path string. + if got := active.runtime.inlinePolicyDirs["inline:ssh"]; got != shadowExtracted { + t.Fatalf("plugins receive %q, want the extends source's path %q", got, shadowExtracted) + } + view := active.runtime.pluginViews["ssh"] + if view == nil { + t.Fatal("the plugin has no view") + } + workDir, err := active.runtime.pluginWorkDir("ssh") + if err != nil || workDir != view.Dir { + t.Fatalf("plugin work dir = %q, %v; want the view %q", workDir, err, view.Dir) + } + + // Inside the view the path is the bundle's tree; outside it, still the vendor's. + tree := active.runtime.inlineTrees["inline:ssh"] + inView, err := filepath.EvalSymlinks(view.Resolve(shadowExtracted)) + if err != nil { + t.Fatal(err) + } + wantTree, _ := filepath.EvalSymlinks(tree) + if inView != wantTree { + t.Fatalf("in the view %s resolves to %s, want the bundle's tree %s", shadowExtracted, inView, wantTree) + } + if raw, err := os.ReadFile(filepath.Join(shadowExtracted, "keys.rego")); err != nil || string(raw) != shadowVendor["keys.rego"] { + t.Fatalf("the agent's own view of the vendor tree must be untouched: %q %v", raw, err) + } + // No continuity policy_id: the path string carries the identity. + raw, err := os.ReadFile(filepath.Join(tree, "banner.rego")) + if err != nil || string(raw) != shadowVendor["banner.rego"] { + t.Fatalf("an inherited module must be the vendor's bytes, got %q %v", raw, err) + } + + // Report: plugin-path is what plugins receive, the extends path too; an old library is + // fine, and no stream warnings. + inline, _ := r78Report(t, h) + if inline.PluginPath != shadowExtracted || inline.Extends.PluginPath != shadowExtracted { + t.Fatalf("plugin-path = %q, extends.plugin-path = %q", inline.PluginPath, inline.Extends.PluginPath) + } + for _, code := range []string{agentconfig.PolicyCodePluginLibInlineUnsupported, inlinepolicy.CodePolicyStreamForked, inlinepolicy.CodeContinuityPolicyIDSkipped, agentconfig.PolicyCodeDuplicatePolicyIdentity} { + if got := policyErrorsWithCode(r, code); len(got) != 0 { + t.Fatalf("unexpected %s: %+v", code, got) + } + } + if len(r.Plugins) != 1 || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported || r.Plugins[0].LibVersion != oldLib { + t.Fatalf("plugins report = %+v", r.Plugins) + } + + // Evidence: inherited and overridden modules keep the vendor UUIDs, added ones get new + // streams, and the modules really load from the bundle (the tuned title). + base, _ := os.Getwd() + vendor := evaluateIn(t, base, shadowExtracted) + got := evaluateIn(t, view.Dir, shadowExtracted) + if len(got) != 3 { + t.Fatalf("expected banner, keys and added evidence in the view, got %+v", got) + } + for _, pkg := range []string{"compliance_framework.banner", "compliance_framework.keys"} { + if got[pkg].uuid == "" || got[pkg].uuid != vendor[pkg].uuid { + t.Fatalf("%s: UUID %s, want the vendor's %s", pkg, got[pkg].uuid, vendor[pkg].uuid) + } + if got[pkg].labels["_policy_path"] != shadowExtracted { + t.Fatalf("%s: _policy_path = %q", pkg, got[pkg].labels["_policy_path"]) + } + if _, ok := got[pkg].labels["_policy_id"]; ok { + t.Fatalf("%s: no _policy_id expected with shadowing", pkg) + } + } + if got["compliance_framework.keys"].title != "Keys (tuned)" { + t.Fatalf("the override must be evaluated, got title %q", got["compliance_framework.keys"].title) + } + added := got["compliance_framework.added"].uuid + if added == "" { + t.Fatal("the added module produced no evidence") + } + for _, e := range vendor { + if e.uuid == added { + t.Fatal("an added module must have its own stream") + } + } +} + +// TestShadow_NewRevisionIsANewView: editing the bundle gives the plugin a new view (no swap +// under a running plugin), the same path, and the same streams; GC removes the old view +// once no candidate uses it. +func TestShadow_NewRevisionIsANewView(t *testing.T) { + h := shadowHarness(t, shadowConfig) + withPluginLib(h, oldLib) + first := mustStartup(t, h.rc) + firstView := first.runtime.pluginViews["ssh"].Dir + base, _ := os.Getwd() + before := evaluateIn(t, firstView, shadowExtracted) + + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"added.rego":"package compliance_framework.added\n\nimport rego.v1\n\ntitle := \"Added v2\"\n\nviolation[{\"id\": \"a\"}] if input.password\n"}}}}`) + resolve := h.rc.resolvePolicy + h.rc = h.newReconciler() // a restart picks the revision up + h.rc.inlineLinks = "" + h.rc.resolvePolicy = resolve + withPluginLib(h, oldLib) + second := mustStartup(t, h.rc) + if second.overlay == nil { + r := h.remote.lastReport(t) + t.Fatalf("the edit must apply: %s/%s %v %+v", r.Status, r.Reason, derefString(r.Error), r.PolicyErrors) + } + secondView := second.runtime.pluginViews["ssh"].Dir + if secondView == firstView { + t.Fatal("a new tree must be a new view") + } + if second.runtime.inlinePolicyDirs["inline:ssh"] != shadowExtracted { + t.Fatal("the plugin path must not change") + } + after := evaluateIn(t, secondView, shadowExtracted) + for pkg, e := range before { + if after[pkg].uuid != e.uuid { + t.Fatalf("%s changed stream across revisions", pkg) + } + } + if after["compliance_framework.added"].title != "Added v2" { + t.Fatalf("the new view must hold the new tree, got %q", after["compliance_framework.added"].title) + } + + h.rc.mu.Lock() + h.rc.active, h.rc.pending, h.rc.starting, h.rc.fallback = second, nil, nil, nil + h.rc.mu.Unlock() + h.rc.gcInline() + if _, err := os.Lstat(firstView); !os.IsNotExist(err) { + t.Fatalf("the old view must be collected: %v", err) + } + if _, err := os.Stat(filepath.Join(secondView, shadowExtracted, "keys.rego")); err != nil { + t.Fatalf("the active view must stay: %v", err) + } + // Removing a view never follows its links. + if _, err := os.Stat(filepath.Join(base, shadowExtracted, "keys.rego")); err != nil { + t.Fatalf("GC must not touch the vendor tree: %v", err) + } +} + +// TestShadow_PluginAlsoLoadingTheSourceFallsBack: a plugin that loads the source and the +// bundle together cannot be given a view; the bundle falls back to R82 (relative inline +// path and continuity policy_id), and the duplicate is reported (R75): a warning from the +// file, an error when the overlay introduces it. +func TestShadow_PluginAlsoLoadingTheSourceFallsBack(t *testing.T) { + t.Run("file", func(t *testing.T) { + h := shadowHarness(t, strings.Replace(shadowConfig, `policies: ["inline:ssh"]`, `policies: ["inline:ssh", "ghcr.io/vendor/policies:v1"]`, 1)) + withPluginLib(h, "v0.9.0") + active := mustStartup(t, h.rc) + if got := active.runtime.inlinePolicyDirs["inline:ssh"]; filepath.ToSlash(got) != ".compliance-framework/policies/inline/ssh/policies" { + t.Fatalf("plugins receive %q, want the R82 path", got) + } + if active.runtime.pluginViews["ssh"] != nil { + t.Fatal("no view without a shadowed bundle") + } + raw, _ := os.ReadFile(filepath.Join(active.runtime.inlineTrees["inline:ssh"], "banner.rego")) + if !strings.Contains(string(raw), `policy_id := "`+shadowExtracted+`/banner.rego"`) { + t.Fatalf("the R82 fallback appends the continuity policy_id, got %q", raw) + } + dups := policyErrorsWithCode(h.remote.lastReport(t), agentconfig.PolicyCodeDuplicatePolicyIdentity) + if len(dups) == 0 || dups[0].Severity != agentconfig.SeverityWarning { + t.Fatalf("expected a duplicate-policy-identity warning, got %+v", h.remote.lastReport(t).PolicyErrors) + } + }) + t.Run("overlay", func(t *testing.T) { + h := shadowHarness(t, shadowConfig) + withPluginLib(h, "v0.9.0") + h.remote.publish(1, `{"plugins":{"ssh":{"policies":["inline:ssh","ghcr.io/vendor/policies:v1"]}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay != nil || r.Status != agentconfig.StatusRejected { + t.Fatalf("loading the source next to its bundle must be rejected, got %s/%s", r.Status, r.Reason) + } + if len(rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyIdentity)) == 0 { + t.Fatalf("expected duplicate-policy-identity errors, got %+v", r.PolicyErrors) + } + if active.runtime.inlinePolicyDirs["inline:ssh"] != shadowExtracted { + t.Fatal("the running file configuration keeps its shadowed bundle") + } + }) +} + +// TestShadow_AbsoluteExtendsNeedsPolicyID: a bundle extending an absolute path cannot be +// shadowed, so continuity needs the policy_id; an old plugin is rejected for it (overlay), +// while the same plugin takes a shadowed bundle. +func TestShadow_AbsoluteExtendsNeedsPolicyID(t *testing.T) { + h, _ := newInlineHarness(t) // the vendor is an absolute temp dir + withPluginLib(h, "v0.7.2") + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\ntitle := \"extra v2\"\n\nviolation[{\"id\": \"x\"}] if input.max > data.max\n"}}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay != nil || r.Status != agentconfig.StatusRejected { + t.Fatalf("expected rejection, got %s/%s", r.Status, r.Reason) + } + gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported) + if len(gate) != 1 || !strings.Contains(gate[0].Message, "cannot be shadowed") || !strings.Contains(gate[0].Message, pluginlib.MinInlinePolicy) { + t.Fatalf("expected the policy_id gate error, got %+v", r.PolicyErrors) + } + if active.runtime.pluginViews["ssh"] != nil { + t.Fatal("an absolute extends path gets no view") + } +} + +// TestShadow_OldLibOverlay: an overlay that edits a shadowed bundle of a plugin built on +// agent v0.1.9 applies; a set-form violation in it is still rejected (old policy-managers +// panic on it). +func TestShadow_OldLibOverlay(t *testing.T) { + t.Run("object form applies", func(t *testing.T) { + h := shadowHarness(t, shadowConfig) + withPluginLib(h, oldLib) + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"new.rego":"package compliance_framework.new\n\nimport rego.v1\n\npolicy_id := \"ssh/new\"\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n"}}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay == nil || r.Status != agentconfig.StatusApplied { + t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + } + ids := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibPolicyIDUnsupported) + if len(ids) != 1 || ids[0].Severity != agentconfig.SeverityWarning || ids[0].Path != "new.rego" { + t.Fatalf("an ignored policy_id is a warning, got %+v", r.PolicyErrors) + } + }) + t.Run("set form is rejected", func(t *testing.T) { + h := shadowHarness(t, shadowConfig) + withPluginLib(h, oldLib) + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"new.rego":"package compliance_framework.new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation contains {\"id\": \"n\"} if input.password\n"}}}}`) + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + if active.overlay != nil || r.Status != agentconfig.StatusRejected { + t.Fatalf("expected rejected, got %s/%s", r.Status, r.Reason) + } + set := rejectionErrors(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported) + if len(set) != 1 || set[0].Path != "new.rego" { + t.Fatalf("expected the set-form error, got %+v", r.PolicyErrors) + } + if got := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported); len(got) != 0 { + t.Fatalf("a shadowed bundle needs no policy_id gate, got %+v", got) + } + }) +} + +// TestLibProblems_Shadowing is the relaxed gate (overlay-introduced bundles). +func TestLibProblems_Shadowing(t *testing.T) { + set := []inlinepolicy.Site{{Path: "s.rego", Row: 1, Col: 1}} + shadowed := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "oci", PluginPath: "rel/policies"}, Shadowed: true} + shadowedSet := &inlinepolicy.Materialized{Name: "b", Extends: shadowed.Extends, Shadowed: true, SetViolations: set} + absolute := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/abs", PluginPath: "/abs"}, Continued: map[string]string{"x.rego": "/abs/x.rego"}} + standalone := &inlinepolicy.Materialized{Name: "b"} + type want struct{ gate, set string } // severity of each code, "" = none + for _, tc := range []struct { + name string + version string + m *inlinepolicy.Materialized + want want + }{ + {"shadowed, v0.1.9", oldLib, shadowed, want{}}, + {"shadowed + set form, v0.1.9", oldLib, shadowedSet, want{set: agentconfig.SeverityError}}, + {"shadowed + set form, v0.7.2", "v0.7.2", shadowedSet, want{}}, + {"shadowed + set form, unknown", "", shadowedSet, want{set: agentconfig.SeverityWarning}}, + {"standalone, v0.1.9", oldLib, standalone, want{}}, + {"absolute extends, v0.1.9", oldLib, absolute, want{gate: agentconfig.SeverityError}}, + {"absolute extends, v0.8.1", "v0.8.1", absolute, want{gate: agentconfig.SeverityError}}, + {"absolute extends, unknown", "", absolute, want{gate: agentconfig.SeverityWarning}}, + } { + t.Run(tc.name, func(t *testing.T) { + var got want + for _, e := range libProblems("ssh", tc.version, []*inlinepolicy.Materialized{tc.m}, true) { + switch e.Code { + case agentconfig.PolicyCodePluginLibInlineUnsupported: + got.gate = e.Severity + case agentconfig.PolicyCodePluginLibViolationSetUnsupported: + got.set = e.Severity + } + } + if got != tc.want { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + }) + } + // The file's own bundles only warn. + for _, e := range libProblems("ssh", oldLib, []*inlinepolicy.Materialized{absolute, shadowedSet}, false) { + if e.Severity != agentconfig.SeverityWarning { + t.Fatalf("file-origin problems must warn: %+v", e) + } + } +} diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index a2ca0ff..ee24358 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -100,6 +100,18 @@ forks every overridden stream, and one older than v0.7.1 crashes on set-form vio inline policies are rejected; file bundles and unknown versions (a `replace` or devel build, which local development relies on) warn. A pseudo-version counts as its base tag, since an untagged commit after v0.8.x or a v0.9.0 RC may not contain R74. +### Path shadowing (prototype) + +Instead of making every plugin honour a continuity `policy_id` (R74/R82, which needs plugins rebuilt on agent ≥ +v0.9.0), a bundle that extends a relative source is given to plugins at the source's own path, and the plugin runs +with a per-plugin view directory as its working directory (`internal/policyview`), in which that path's parent links +to the bundle's tree and everything else mirrors the agent's working directory. Evidence identity is then the vendor's +by construction, for every plugin build. The agent resolves every relative policy path of such a plugin through its +view (artifact uploads, source props). R82's `policy_id` remains the fallback where a view cannot represent the paths +(absolute `extends`, a plugin loading the source and the bundle together), and the R79 gate only applies there. +Risk: a plugin that relies on its working directory sees the view (mirrored, so reads and writes inside existing +directories still reach the agent's; new top-level files stay in the view). + ### Stable inline paths (R67) `policy-manager` seeds evidence UUIDs with the policy file path. Materialized bundles stay write-once, diff --git a/docs/configuration.md b/docs/configuration.md index 0ac6fc4..0aa51e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -300,6 +300,27 @@ policy_bundles: appended line; `extends.files[]` keeps the vendor's own hashes. A changed `package` is a `policy-package-changed` warning; an explicit `policy_id` that does not continue the vendor stream is a `policy-stream-forked` warning, which names the `policy_id` that would continue it. +- **Path shadowing (prototype; takes precedence over the R82 `policy_id` above).** Plugins seed evidence UUIDs from + the policy path *string* they receive, so a plugin that keeps receiving the vendor's path keeps the vendor's + streams, whatever agent library it was built with. When a bundle `extends` a source whose plugin path is relative + and ends in `policies/` (every OCI source, e.g. + `.compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies`), plugins receive the + bundle **at that exact path**, and each plugin that uses such a bundle runs in its own **view**, + `/views///`, as its working directory (the plugin binary is started by absolute path). In + the view, the path's parent is a symlink to the bundle's content-addressed directory (whose `policies/` is real: + OPA loads nothing from a symlinked root), and every other entry of the agent's working directory is mirrored as a + symlink, so every other relative path resolves as it does for the agent. The tree gets **no** continuity + `policy_id`: inherited and overridden modules continue the vendor streams through the path alone, new modules + start their own path-based streams, deleted ones stop. `plugin-path` (and `extends.plugin-path`) report the + vendor path; `_policy_source` says `inline:` (also for plugins that send no policy evaluations, from + their `_policy_path` label) and evaluation-time artifacts are read through the view, i.e. from the bundle's + tree. Views are content-addressed (a new revision is a new view, nothing is swapped under a running plugin) and + garbage-collected with the inline trees. A bundle is **not** shadowed, and falls back to R82, when its `extends` + path is absolute (or not a `policies/` tree), when a plugin using it also loads the source itself (reported as + `duplicate-policy-identity` as before) or another bundle extending the same source, when another relative policy + path of the plugin cannot be represented in a view (e.g. a single-component path such as `policies`), or without + symlinks. A plugin running in a view sees its working directory as the view: files it creates there (rather than + through a mirrored directory) stay in the view and are removed with it. - **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any `.rego` file fails with `download-failed`. - **Where bundles live (R67, R82).** Inline bundles are never downloaded. Each revision of a bundle is a write-once @@ -370,29 +391,25 @@ policy_id := "ssh-deny-password-auth" **Plugins must be rebuilt.** Plugins seed evidence with the `policy-manager` they embed, so `policy_id` only takes effect for plugins built on an agent library that includes it (agent ≥ v0.9.0, `pluginlib.MinInlinePolicy`). -### Plugin compatibility (R76, R79) +### Plugin compatibility (R76, R79, relaxed by path shadowing) The agent reads each plugin's agent library version from the binary's Go build info, without starting it, and -reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`: `supported`, `unsupported` or -`unknown`). - -- **Inline policies need agent ≥ v0.9.0** (the first release with `policy_id`; it also covers set-form violations). - v0.8.0 and v0.8.1 were released without `policy_id`, and v0.9.0 release candidates (`v0.9.0-rc*`) count as older - than v0.9.0, so they are `unsupported`, as are pseudo-versions built after them. - An overlay that gives an `inline:` entry to a plugin built on an older library, changes a bundle such a plugin - uses, or moves a plugin that uses one to such a build (its `source`), is rejected before it is applied with `plugin-lib-inline-unsupported` ("plugin `

` (agent lib ``) doesn't - support inline policies; upgrade the plugin to a build on agent ≥ v0.9.0"); the running configuration keeps - running. +reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`). With path shadowing, +`inline-policies` is `supported` for every known library version (each bundle is checked against it, below) and +`unknown` when the version is unknown; `unsupported` is no longer reported. + +- **Shadowed bundles and bundles without `extends` work with any plugin build**: continuity comes from the path. +- **Bundles that extend a source but are not shadowed** (absolute `extends` path, a plugin that also loads the + source, no symlinks) keep the vendor streams only through the continuity `policy_id`, which needs agent ≥ v0.9.0 + (`pluginlib.MinInlinePolicy`). For an older plugin an overlay that introduces such a bundle is rejected with + `plugin-lib-inline-unsupported`; the running configuration keeps running. - **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect - `violation[{...}] if { ... }`. An authored module that uses them for such a plugin is also named - (`plugin-lib-violation-set-unsupported`), with that fix. -- **Unknown versions are warnings**: a `replace`d or `(devel)` build, a pseudo-version with no tag before it, or a - binary without build info. Local plugin builds therefore keep working. -- **File-defined inline bundles only warn** (R34), and for them each authored `policy_id` the plugin would ignore is a - `plugin-lib-policy-id-unsupported` warning ("this module starts a new evidence stream"). -- A pseudo-version counts as the tag it was built after: a plugin built on an unreleased commit after v0.8.x or a - v0.9.0 release candidate is `unsupported` until it moves to a v0.9.0 build (or a `replace`, which is `unknown`); - one built on a commit after v0.9.0 is `supported`. + `violation[{...}] if { ... }`: an overlay-introduced authored module that uses them for such a plugin is rejected + with `plugin-lib-violation-set-unsupported`, with that fix. +- **An authored `policy_id`** is ignored by plugins older than v0.9.0 (the module's stream follows its path): + `plugin-lib-policy-id-unsupported` warning. +- **Unknown versions and file-defined bundles only warn** (R34): a `replace`d or `(devel)` build, a pseudo-version + with no tag before it, or a binary without build info. ## Remote configuration diff --git a/internal/inlinepolicy/continuity_test.go b/internal/inlinepolicy/continuity_test.go index be846a4..f45aae5 100644 --- a/internal/inlinepolicy/continuity_test.go +++ b/internal/inlinepolicy/continuity_test.go @@ -292,3 +292,29 @@ func sha(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:]) } + +// TestShadow_MaterializeWithoutContinuityPolicyID: with Options.Shadow and a shadowable +// extends path, plugins receive the extends path itself and the tree is written without +// continuity policy_ids; an absolute extends path ignores the option (R82 fallback). +func TestShadow_MaterializeWithoutContinuityPolicyID(t *testing.T) { + l, resolve := r82Setup(t) + b := &agentconfig.PolicyBundle{ + Extends: strptr("ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0"), + Modules: map[string]string{"custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n"}, + } + m, err := Materialize(context.Background(), l, "custom", b, resolve, Options{Shadow: true}) + require.NoError(t, err) + assert.True(t, m.Shadowed) + assert.Equal(t, r82Vendor, m.Path, "plugins receive the vendor's path string") + assert.Empty(t, m.Continued) + assert.Equal(t, r82VendorFiles["banner.rego"], readFile(t, m.Dir, "banner.rego"), "inherited modules keep the vendor bytes") + assert.Empty(t, OverrideStreams(m)) + + abs, err := filepath.Abs(r82Vendor) + require.NoError(t, err) + m, err = Materialize(context.Background(), l, "custom", b, func(context.Context, string) (string, error) { return abs, nil }, Options{Shadow: true}) + require.NoError(t, err) + assert.False(t, m.Shadowed, "an absolute extends path cannot be shadowed") + assert.Equal(t, ".compliance-framework/policies/inline/custom/policies", filepath.ToSlash(m.Path)) + assert.NotEmpty(t, m.Continued, "the R82 continuity policy_id is the fallback") +} diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 32586ee..6d9e5ca 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -25,6 +25,7 @@ import ( "sync" "github.com/compliance-framework/agent/internal/policytree" + "github.com/compliance-framework/agent/internal/policyview" "github.com/compliance-framework/api/pkg/agentconfig" "sigs.k8s.io/yaml" ) @@ -106,12 +107,29 @@ type Materialized struct { // Continued maps the modules the agent appended a continuity policy_id to (R82), by // path, to that policy_id. Continued map[string]string + // Shadowed is set when plugins receive the bundle at the extends source's own path, + // resolved to Dir inside each plugin's view (path shadowing, see internal/policyview): + // Path is then Extends.PluginPath, and no continuity policy_id is appended, because the + // path string alone keeps the vendor's evidence streams. + Shadowed bool +} + +// Options change how Materialize writes a bundle. +type Options struct { + // Shadow asks for path shadowing: when the bundle extends a source whose plugin path + // policyview.Shadowable accepts, plugins receive that path (Materialized.Shadowed) and + // the tree is written without continuity policy_ids. Otherwise it is ignored. + Shadow bool } // Materialize builds bundle name in the R17 order (extends tree, delete, modules, data), // checks the data-file rule (R18), appends the continuity policy_id to the modules that // continue a vendor file (R82) and writes the result write-once under l.Store. -func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.PolicyBundle, resolve Resolver) (*Materialized, error) { +func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.PolicyBundle, resolve Resolver, opts ...Options) (*Materialized, error) { + var opt Options + for _, o := range opts { + opt.Shadow = opt.Shadow || o.Shadow + } if b == nil { return nil, PolicyErrors{{Bundle: name, Message: "bundle has no definition", Severity: agentconfig.SeverityError}} } @@ -230,8 +248,9 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli // The authored constructs, before the agent adds anything. m.SetViolations, m.PolicyIDRules = authoredSites(files, m.Authored) - // 6. Continuity policy_id (R82). - if m.Extends != nil { + // 6. Continuity: path shadowing, or else the continuity policy_id (R82). + m.Shadowed = opt.Shadow && m.Extends != nil && policyview.Shadowable(m.ExtendsDir) == nil + if m.Extends != nil && !m.Shadowed { m.Warnings = append(m.Warnings, continueVendorStreams(m, files, vendorFiles)...) } @@ -243,7 +262,10 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli } m.Dir = filepath.Join(final, treeDir) m.Path = m.Dir - if symlinksSupported(l.Links) { + switch { + case m.Shadowed: + m.Path = m.ExtendsDir + case symlinksSupported(l.Links): m.Path = filepath.Join(l.Links, name, treeDir) } @@ -578,3 +600,6 @@ func sortedKeys[V any](m map[string]V) []string { slices.Sort(keys) return keys } + +// SymlinksSupported reports, once per root, whether symlinks can be created under root. +func SymlinksSupported(root string) bool { return symlinksSupported(root) } diff --git a/internal/policyview/policyview.go b/internal/policyview/policyview.go new file mode 100644 index 0000000..c814960 --- /dev/null +++ b/internal/policyview/policyview.go @@ -0,0 +1,319 @@ +// Package policyview builds per-plugin working directories ("views") in which a plugin sees +// an inline policy bundle at the exact relative path of the source the bundle extends (path +// shadowing, prototype). +// +// Plugins seed evidence UUIDs from the path string the agent passes them (policy-manager: +// policy_file = /, label _policy_path = ). A plugin that keeps receiving the +// vendor's path string therefore keeps the vendor's evidence streams, whatever agent library +// it was built with. A view makes that path string resolve to the inline bundle's tree: +// +// /.compliance-framework/policies// -> // (symlink) +// /.compliance-framework/policies///policies (real dir, inside the tree) +// +// The plugin process is started with the view as its working directory. The link sits at +// the path's parent, never at the leaf, because OPA's bundle loader loads nothing from a +// symlinked root directory but resolves a symlink earlier in the path like any other. +// +// Every other entry of the agent's working directory is mirrored into the view: each real +// directory of the view (the ancestors of the links) holds a symlink to every entry of the +// same directory in the agent's working directory that is not itself a view directory or a +// link. So every other relative path the plugin receives, or opens on its own, resolves as +// it does for the agent (reading and writing through the mirrored links), except new +// entries the plugin creates directly in a view directory, which stay in the view. +// +// A view is content-addressed by its links and base: a new bundle revision is a new view, so +// nothing is ever swapped under a running plugin. Ensure is idempotent and only adds missing +// mirror links, so it can run before every plugin run. +// +// The package is a leaf: it imports only the standard library. +package policyview + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strings" +) + +// TreeDir is the last component a shadowed path must have: the inline store keeps each +// bundle's tree in a real policies/ directory, which the view link's target contains. +const TreeDir = "policies" + +// View is one plugin's working directory. +type View struct { + // Dir is the view directory (absolute). + Dir string + // Base is the agent's working directory (absolute): what every relative path that is not + // shadowed resolves against. + Base string + // Links maps a slash-separated path relative to the view to the absolute directory it + // links to. + Links map[string]string +} + +// Shadowable reports whether a plugin path can be shadowed: a relative path, inside the +// working directory, with a parent below it, whose last component is TreeDir (every OCI +// source; a local source only when laid out the same way). +func Shadowable(pluginPath string) error { + if pluginPath == "" { + return errors.New("empty path") + } + if filepath.IsAbs(pluginPath) || filepath.VolumeName(pluginPath) != "" { + return fmt.Errorf("%s is absolute; only relative paths can be shadowed", pluginPath) + } + clean := filepath.Clean(pluginPath) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return fmt.Errorf("%s is outside the working directory", pluginPath) + } + if filepath.Base(clean) != TreeDir { + return fmt.Errorf("%s does not end in %s/", pluginPath, TreeDir) + } + if filepath.Dir(clean) == "." { + return fmt.Errorf("%s has no parent directory to link", pluginPath) + } + return nil +} + +// LinkOf is the view path linked for shadowed plugin path p (its cleaned parent). +func LinkOf(p string) string { + return filepath.ToSlash(filepath.Dir(filepath.Clean(p))) +} + +// Plan checks that a plugin whose policy paths are shadowed (shadowed plugin paths) and +// others (every other policy path it receives) can be given one view, and returns the view +// link of each shadowed path. It fails when: +// +// - two shadowed paths are the same, or one's link is inside another's; +// - another relative path resolves into a shadowed tree, or is itself a view directory or +// sits directly in one (its leaf would be a mirrored symlink, which OPA does not load). +// +// Absolute paths are unaffected by the view. +func Plan(shadowed, others []string) (map[string]string, error) { + links := map[string]string{} + seen := map[string]string{} + for _, p := range shadowed { + if err := Shadowable(p); err != nil { + return nil, err + } + link := LinkOf(p) + if prev, dup := seen[link]; dup { + return nil, fmt.Errorf("%s and %s are the same path", prev, p) + } + seen[link] = p + links[p] = link + } + for a := range seen { + for b := range seen { + if a != b && within(b, a) { + return nil, fmt.Errorf("%s is inside %s", seen[b], seen[a]) + } + } + } + dirs := viewDirs(seen) + for _, p := range others { + if filepath.IsAbs(p) || filepath.VolumeName(p) != "" { + continue + } + clean := filepath.ToSlash(filepath.Clean(p)) + for link, sp := range seen { + if clean == link || within(clean, link) { + return nil, fmt.Errorf("%s would resolve into the shadowed tree of %s", p, sp) + } + } + if dirs[clean] { + return nil, fmt.Errorf("%s is a parent of a shadowed path", p) + } + if parent := pathDir(clean); dirs[parent] { + return nil, fmt.Errorf("%s would be a symlinked policy root in the view (its parent %s holds a shadowed path)", p, parent) + } + } + return links, nil +} + +// within reports whether slash path p is strictly inside dir. +func within(p, dir string) bool { + return dir == "." || strings.HasPrefix(p, dir+"/") +} + +func pathDir(p string) string { + return filepath.ToSlash(filepath.Dir(filepath.FromSlash(p))) +} + +// viewDirs are the real directories of a view with links: every proper ancestor of a link, +// and the view root ".". +func viewDirs[V any](links map[string]V) map[string]bool { + dirs := map[string]bool{".": true} + for link := range links { + for d := pathDir(link); d != "." && !dirs[d]; d = pathDir(d) { + dirs[d] = true + } + } + return dirs +} + +var safeName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,62}$`) + +// DirFor is the view directory of plugin under root for a view with links over base: +// //. +func DirFor(root, plugin, base string, links map[string]string) string { + name := plugin + if !safeName.MatchString(plugin) { + sum := sha256.Sum256([]byte(plugin)) + name = "p-" + hex.EncodeToString(sum[:8]) + } + h := sha256.New() + _, _ = fmt.Fprintf(h, "%d:%s\n", len(base), base) + keys := sortedKeys(links) + for _, k := range keys { + _, _ = fmt.Fprintf(h, "%d:%s=%d:%s\n", len(k), k, len(links[k]), links[k]) + } + return filepath.Join(root, name, hex.EncodeToString(h.Sum(nil))[:16]) +} + +// Ensure creates the view: its directories, its links, and the mirror links of the base's +// entries. It never removes or changes an entry that is already right, so it is safe to run +// while a plugin uses the view. +func (v View) Ensure() error { + if !filepath.IsAbs(v.Dir) || !filepath.IsAbs(v.Base) { + return fmt.Errorf("view %s: the view and base directories must be absolute", v.Dir) + } + dirs := viewDirs(v.Links) + var errs []error + for _, d := range sortedKeys(dirs) { + if err := os.MkdirAll(filepath.Join(v.Dir, filepath.FromSlash(d)), 0o755); err != nil { + return fmt.Errorf("view %s: %w", v.Dir, err) + } + } + for _, link := range sortedKeys(v.Links) { + if err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(link)), v.Links[link]); err != nil { + errs = append(errs, err) + } + } + for _, d := range sortedKeys(dirs) { + entries, err := os.ReadDir(filepath.Join(v.Base, filepath.FromSlash(d))) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + errs = append(errs, err) + continue + } + for _, e := range entries { + child := e.Name() + if d != "." { + child = d + "/" + child + } + if _, linked := v.Links[child]; linked || dirs[child] { + continue + } + target := filepath.Join(v.Base, filepath.FromSlash(child)) + if contains(target, v.Dir) { + continue // never mirror a directory that holds the view itself + } + if err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(child)), target); err != nil { + errs = append(errs, err) + } + } + } + if err := errors.Join(errs...); err != nil { + return fmt.Errorf("view %s: %w", v.Dir, err) + } + return nil +} + +// Resolve returns the path the plugin opens for pluginPath, as seen from outside the view: +// relative paths under the view, absolute ones unchanged. +func (v View) Resolve(pluginPath string) string { + if filepath.IsAbs(pluginPath) { + return pluginPath + } + return filepath.Join(v.Dir, pluginPath) +} + +// contains reports whether p is dir or inside it. +func contains(dir, p string) bool { + rel, err := filepath.Rel(dir, p) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// ensureSymlink makes path a symlink to target: a no-op when it already is, a new link, or +// an atomic replacement (temporary link renamed over it) of a link to something else. +func ensureSymlink(path, target string) error { + cur, err := os.Readlink(path) + switch { + case err == nil && cur == target: + return nil + case err == nil: + tmp := path + ".tmp-link" + _ = os.Remove(tmp) + if err := os.Symlink(target, tmp); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil + } + if _, statErr := os.Lstat(path); statErr == nil { + return fmt.Errorf("%s exists and is not a symlink", path) + } + if err := os.Symlink(target, path); err != nil && !os.IsExist(err) { + return err + } + return nil +} + +// GC removes the views under root (//) that are not in keep (View.Dir +// values). Removing a view never follows its links. +func GC(root string, keep map[string]struct{}) error { + plugins, err := os.ReadDir(root) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + var errs []error + for _, p := range plugins { + if !p.IsDir() { + continue + } + pluginDir := filepath.Join(root, p.Name()) + views, err := os.ReadDir(pluginDir) + if err != nil { + errs = append(errs, err) + continue + } + left := 0 + for _, v := range views { + dir := filepath.Join(pluginDir, v.Name()) + if _, ok := keep[dir]; ok { + left++ + continue + } + errs = append(errs, os.RemoveAll(dir)) + } + if left == 0 { + _ = os.Remove(pluginDir) + } + } + return errors.Join(errs...) +} + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} diff --git a/internal/policyview/policyview_test.go b/internal/policyview/policyview_test.go new file mode 100644 index 0000000..e5b48bd --- /dev/null +++ b/internal/policyview/policyview_test.go @@ -0,0 +1,124 @@ +package policyview + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const oci = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" + +func TestShadowable(t *testing.T) { + for p, ok := range map[string]bool{ + oci: true, + "./" + oci: true, + "vendor/policies": true, + "policies": false, // no parent to link + "./policies": false, + "/abs/x/policies": false, + "../x/policies": false, + ".compliance-framework/x": false, // not a policies/ tree + "": false, + "a/../b/policies": true, + "a/policies/../x/policies": true, + } { + assert.Equal(t, ok, Shadowable(p) == nil, p) + } +} + +func TestPlan(t *testing.T) { + links, err := Plan([]string{oci}, []string{ + ".compliance-framework/policies/inline/custom/policies", // R82 inline path + ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies", // another tag: a mirrored sibling + "/etc/ccf/policies", // absolute: unaffected + }) + require.NoError(t, err) + assert.Equal(t, map[string]string{oci: ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0"}, links) + + for name, tc := range map[string]struct{ shadowed, others []string }{ + "same path twice": {[]string{oci, "./" + oci}, nil}, + "nested links": {[]string{"a/policies", "a/policies/b/policies"}, nil}, + "other inside a shadow": {[]string{oci}, []string{oci + "/sub"}}, + "other is the shadowed path": {[]string{oci}, []string{oci}}, + "other is a view directory": {[]string{oci}, []string{".compliance-framework/policies"}}, + "other directly in a view dir (leaf would be a mirrored symlink)": {[]string{"vendor/policies"}, []string{"local-policies"}}, + "not shadowable": {[]string{"/abs/policies"}, nil}, + } { + _, err := Plan(tc.shadowed, tc.others) + assert.Error(t, err, name) + } +} + +func TestEnsure(t *testing.T) { + base := t.TempDir() + mk := func(p, content string) { + require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(base, p)), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(base, p), []byte(content), 0o644)) + } + mk(oci+"/vendor.rego", "vendor") + mk(".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies/old.rego", "old") + mk(".compliance-framework/policies/inline/custom/policies/x.rego", "inline") + mk("config.yml", "cfg") + target := filepath.Join(t.TempDir(), "b", "0123") + require.NoError(t, os.MkdirAll(filepath.Join(target, "policies"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(target, "policies", "bundle.rego"), []byte("bundle"), 0o644)) + + links := map[string]string{LinkOf(oci): target} + v := View{Dir: DirFor(filepath.Join(t.TempDir(), "views"), "ssh", base, links), Base: base, Links: links} + if err := v.Ensure(); err != nil { + if os.IsPermission(err) { + t.Skip(err) + } + require.NoError(t, err) + } + read := func(p string) string { + raw, err := os.ReadFile(filepath.Join(v.Dir, p)) + require.NoError(t, err, p) + return string(raw) + } + assert.Equal(t, "bundle", read(oci+"/bundle.rego"), "the shadowed path is the bundle") + _, err := os.Stat(filepath.Join(v.Dir, oci, "vendor.rego")) + assert.True(t, os.IsNotExist(err), "the vendor tree is hidden") + assert.Equal(t, "old", read(".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies/old.rego")) + assert.Equal(t, "inline", read(".compliance-framework/policies/inline/custom/policies/x.rego")) + assert.Equal(t, "cfg", read("config.yml"), "other entries of the working directory are mirrored") + info, err := os.Lstat(filepath.Join(v.Dir, oci)) + require.NoError(t, err) + assert.True(t, info.IsDir(), "the leaf is a real directory (OPA does not load a symlinked root)") + + // Idempotent, and picks up entries created since. + mk("later.txt", "later") + require.NoError(t, v.Ensure()) + assert.Equal(t, "later", read("later.txt")) + + // Deterministic directory, and a different target is a different view. + assert.Equal(t, v.Dir, DirFor(filepath.Dir(filepath.Dir(v.Dir)), "ssh", base, links)) + assert.NotEqual(t, v.Dir, DirFor(filepath.Dir(filepath.Dir(v.Dir)), "ssh", base, map[string]string{LinkOf(oci): target + "x"})) + + // GC removes unkept views without following their links. + other := View{Dir: DirFor(filepath.Dir(filepath.Dir(v.Dir)), "ssh", base, map[string]string{LinkOf(oci): target}), Base: base, Links: links} + require.Equal(t, v.Dir, other.Dir) + require.NoError(t, GC(filepath.Dir(filepath.Dir(v.Dir)), map[string]struct{}{})) + _, err = os.Lstat(v.Dir) + assert.True(t, os.IsNotExist(err)) + _, err = os.Stat(filepath.Join(target, "policies", "bundle.rego")) + assert.NoError(t, err, "GC must not follow the view's links") + _, err = os.Stat(filepath.Join(base, oci, "vendor.rego")) + assert.NoError(t, err) +} + +func TestEnsureNeverMirrorsTheViewIntoItself(t *testing.T) { + base := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(base, "vendor", "policies"), 0o755)) + target := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(target, "policies"), 0o755)) + links := map[string]string{"vendor": target} + // The views live under the base's state directory, like the agent's. + v := View{Dir: DirFor(filepath.Join(base, "state", "views"), "ssh", base, links), Base: base, Links: links} + require.NoError(t, v.Ensure()) + _, err := os.Lstat(filepath.Join(v.Dir, "state")) + assert.True(t, os.IsNotExist(err), "the directory holding the view is not mirrored") +} diff --git a/runner/result.go b/runner/result.go index 6871908..768cce5 100644 --- a/runner/result.go +++ b/runner/result.go @@ -20,6 +20,10 @@ type apiHelper struct { // policyPaths maps each policy path the plugin was given (cleaned) to the directory it // resolved to when the helper was created. policyPaths map[string]string + // rawPolicyPaths and policyRoot are what WithPolicyPaths and WithPolicyRoot set; + // NewApiHelper resolves them into policyPaths. + rawPolicyPaths []string + policyRoot string // evidenceProps are appended to every evidence the plugin creates. evidenceProps []types.Property @@ -56,6 +60,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: @@ -83,14 +91,32 @@ type ApiHelperOption func(*apiHelper) // configuration runs, and the artifact must be the tree this run evaluated. func WithPolicyPaths(paths []string) ApiHelperOption { return func(h *apiHelper) { - for _, path := range paths { - clean := filepath.Clean(path) - dir := clean - if resolved, err := filepath.EvalSymlinks(clean); err == nil { - dir = resolved - } - h.policyPaths[clean] = dir + h.rawPolicyPaths = append(h.rawPolicyPaths, paths...) + } +} + +// WithPolicyRoot sets the working directory the plugin runs in (its view, when it receives +// a shadowed inline bundle): relative policy paths resolve against it, as they do for the +// plugin, so the agent reads the tree the plugin evaluated. Empty means the agent's own +// working directory. +func WithPolicyRoot(dir string) ApiHelperOption { + return func(h *apiHelper) { + h.policyRoot = dir + } +} + +// resolvePolicyPaths fills policyPaths from rawPolicyPaths and policyRoot. +func (h *apiHelper) resolvePolicyPaths() { + for _, path := range h.rawPolicyPaths { + clean := filepath.Clean(path) + dir := clean + if h.policyRoot != "" && !filepath.IsAbs(clean) { + dir = filepath.Join(h.policyRoot, clean) + } + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + dir = resolved } + h.policyPaths[clean] = dir } } @@ -128,6 +154,7 @@ func NewApiHelper(logger hclog.Logger, client *sdk.Client, agentLabels map[strin for _, opt := range opts { opt(h) } + h.resolvePolicyPaths() if h.uploader == nil { h.uploader = NewArtifactUploader() } @@ -240,6 +267,15 @@ func (h *apiHelper) toSdk(e *proto.Evidence, outcome evaluationOutcome) types.Ev } } evid.Props = appendSource(props, h.pluginSource, PropPluginSource, PropPluginDigest) + 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.policySource(outcome), PropPolicySource, PropPolicyDigest) } diff --git a/runner/shadow_test.go b/runner/shadow_test.go new file mode 100644 index 0000000..b6d2866 --- /dev/null +++ b/runner/shadow_test.go @@ -0,0 +1,92 @@ +package runner + +import ( + "context" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/compliance-framework/agent/internal/policytree" + "github.com/compliance-framework/agent/runner/proto" + "github.com/compliance-framework/api/sdk" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Path shadowing: a plugin running in a view receives a relative path that, in the view, +// resolves to an inline bundle while, from the agent's working directory, it is the vendor +// source. The agent must read what the plugin evaluated. + +const shadowedPath = ".compliance-framework/policies/vendor/policies/v1/policies" + +// shadowFixture returns a base (the agent's working directory, with the vendor tree at +// shadowedPath) and a view in which shadowedPath is the bundle's tree. +func shadowFixture(t *testing.T) (base, view, bundleTree string) { + t.Helper() + base = t.TempDir() + vendor := filepath.Join(base, shadowedPath) + require.NoError(t, os.MkdirAll(vendor, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(vendor, "a.rego"), []byte("package compliance_framework.a\n\ntitle := \"vendor\"\n"), 0o644)) + + store := filepath.Join(t.TempDir(), "inline", "b", "0123") + bundleTree = filepath.Join(store, "policies") + require.NoError(t, os.MkdirAll(bundleTree, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(bundleTree, "a.rego"), []byte("package compliance_framework.a\n\ntitle := \"inline\"\n"), 0o644)) + + view = t.TempDir() + link := filepath.Join(view, filepath.Dir(shadowedPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o755)) + if err := os.Symlink(store, link); err != nil { + t.Skip("symlinks are not supported here") + } + t.Chdir(base) + return base, view, bundleTree +} + +func TestPolicyRootResolvesRelativePathsInTheView(t *testing.T) { + base, view, bundleTree := shadowFixture(t) + abs := t.TempDir() + + h := NewApiHelper(hclog.NewNullLogger(), nil, nil, "ssh", WithPolicyRoot(view), WithPolicyPaths([]string{shadowedPath, abs})) + want, err := filepath.EvalSymlinks(bundleTree) + require.NoError(t, err) + assert.Equal(t, want, h.policyPaths[shadowedPath], "the agent reads the tree the plugin evaluated, not the vendor's") + wantAbs, _ := filepath.EvalSymlinks(abs) + assert.Equal(t, wantAbs, h.policyPaths[abs], "absolute paths are unaffected") + + inline, err := policytree.TarDirectory(h.policyPaths[shadowedPath]) + require.NoError(t, err) + vendor, err := policytree.TarDirectory(filepath.Join(base, shadowedPath)) + require.NoError(t, err) + assert.NotEqual(t, vendor, inline) + + // Without a root (no view) the agent's working directory is used, as before. + plain := NewApiHelper(hclog.NewNullLogger(), nil, nil, "ssh", WithPolicyPaths([]string{shadowedPath})) + assert.Equal(t, shadowedPath, plain.policyPaths[shadowedPath]) +} + +// TestPolicySourceFromThePolicyPathLabel: a plugin built on an agent library without policy +// evaluations still labels its evidence with _policy_path; the agent records the source of +// that path, which for a shadowed path is the inline bundle. +func TestPolicySourceFromThePolicyPathLabel(t *testing.T) { + _, view, _ := shadowFixture(t) + api := &fakeAPI{} + server := httptest.NewServer(api) + t.Cleanup(server.Close) + client := sdk.NewClient(server.Client(), &sdk.Config{BaseURL: server.URL}) + inline := Source{Reference: "inline:b", Digest: "tree:sha256:abcd", BundleArtifact: true} + h := NewApiHelper(hclog.NewNullLogger(), client, nil, "ssh", + WithPolicyRoot(view), WithPolicyPaths([]string{shadowedPath}), + WithSources(testPlugin, map[string]Source{shadowedPath: inline})) + + labelled := &proto.Evidence{UUID: "11111111-1111-1111-1111-111111111111", Title: "old plugin", Labels: map[string]string{LabelPolicyPath: shadowedPath}} + unknown := &proto.Evidence{UUID: "11111111-1111-1111-1111-111111111112", Title: "other path", Labels: map[string]string{LabelPolicyPath: "elsewhere"}} + require.NoError(t, h.CreateEvidence(context.Background(), []*proto.Evidence{labelled, unknown})) + + props := sentProps(api) + assert.Equal(t, "inline:b", props["old plugin"][PropPolicySource]) + assert.Equal(t, "tree:sha256:abcd", props["old plugin"][PropPolicyDigest], "no artifact was stored: the tree digest") + assert.NotContains(t, props["other path"], PropPolicySource, "a path the plugin was not given records nothing") +} From 6a5fc347ca376c4fd2e5c6e142f37836da9c17be Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Thu, 1 Oct 2026 17:09:46 -0300 Subject: [PATCH 33/47] wip(shadow): view collisions are plugin-owned, never fatal (rule 1) A plugin that creates an entry relative to its working directory (e.g. cloud-custodian's debug-standardized-payloads/) creates it in its view. When the agent's working directory later gets the same name, Ensure failed with "exists and is not a symlink", aborting the plugin's run and the configuration's activation. A real (non-symlink) entry in a view is now plugin-owned: Ensure keeps it untouched, warns once per (view, name) through View.Warn (the reconciler's hclog Warn), and continues. Only the shadow link itself is agent-owned: a real entry at its path is a *LinkConflictError that fails activation and the run with a clear message and is never removed (falling back to R82 would need re-materializing the bundle, which is decided before materializing). ensureSymlink no longer removes a pre-existing ".tmp-link" entry (unique temp names instead), so it can only ever replace symlinks. GC still removes whole views, never follows links, and forgets the warnings of removed views. Docs: plugin contract note in configuration.md and the ADR. Co-Authored-By: Claude Opus 5.5 --- cmd/shadow.go | 2 + cmd/shadow_test.go | 68 ++++++++ docs/adr/0003-remote-config-overlay.md | 8 + docs/configuration.md | 8 + internal/policyview/policyview.go | 110 +++++++++++-- internal/policyview/policyview_test.go | 211 +++++++++++++++++++++++++ 6 files changed, 395 insertions(+), 12 deletions(-) diff --git a/cmd/shadow.go b/cmd/shadow.go index 32482e5..98267b6 100644 --- a/cmd/shadow.go +++ b/cmd/shadow.go @@ -230,6 +230,8 @@ func (rc *reconciler) buildViews(plan shadowPlan, materialized map[string]*inlin Dir: policyview.DirFor(root, pluginName, base, viewLinks), Base: base, Links: viewLinks, + // Plugin-owned entries in the view (rule 1) are warnings, once per view and name. + Warn: rc.logger.Warn, } } return views, nil diff --git a/cmd/shadow_test.go b/cmd/shadow_test.go index f20bcb9..615ef2c 100644 --- a/cmd/shadow_test.go +++ b/cmd/shadow_test.go @@ -10,6 +10,7 @@ import ( "github.com/compliance-framework/agent/internal/inlinepolicy" "github.com/compliance-framework/agent/internal/pluginlib" + "github.com/compliance-framework/agent/internal/policyview" policy_manager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/hashicorp/go-hclog" @@ -422,3 +423,70 @@ func TestLibProblems_Shadowing(t *testing.T) { } } } + +// TestShadow_PluginOwnedViewEntries (rule 1): a plugin that creates a directory relative to +// its working directory creates it in its view. When the agent's working directory later +// gets the same name, the plugin keeps its own; neither activation nor the plugin's run +// fails, and the warning is logged once. A real entry at the shadow link itself is a +// conflict: activation and the run fail with a clear error, and the entry is left alone. +func TestShadow_PluginOwnedViewEntries(t *testing.T) { + h := shadowHarness(t, shadowConfig) + withPluginLib(h, oldLib) + var logs strings.Builder + h.rc.logger = hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Warn}) + active := mustStartup(t, h.rc) + if err := h.rc.activateInline(active); err != nil { + t.Fatal(err) + } + view := active.runtime.pluginViews["ssh"] + if view == nil { + t.Fatal("the plugin has no view") + } + + // cloud-custodian writes debug-standardized-payloads/ relative to its working directory. + owned := filepath.Join(view.Dir, "debug-standardized-payloads") + if err := os.MkdirAll(owned, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(owned, "payload.json"), []byte("plugin"), 0o644); err != nil { + t.Fatal(err) + } + // ... and later, so does something in the agent's working directory. + if err := os.MkdirAll("debug-standardized-payloads", 0o755); err != nil { + t.Fatal(err) + } + + for range 3 { + if err := h.rc.activateInline(active); err != nil { + t.Fatalf("activation must not fail on a plugin-owned entry: %v", err) + } + if dir, err := active.runtime.pluginWorkDir("ssh"); err != nil || dir != view.Dir { + t.Fatalf("the plugin's run must not fail on a plugin-owned entry: %q %v", dir, err) + } + } + if n := strings.Count(logs.String(), "path=debug-standardized-payloads"); n != 1 || strings.Count(logs.String(), "[WARN]") != 1 { + t.Fatalf("want one warning naming the entry, got %d:\n%s", n, logs.String()) + } + if raw, err := os.ReadFile(filepath.Join(owned, "payload.json")); err != nil || string(raw) != "plugin" { + t.Fatalf("the plugin's entry must be kept: %q %v", raw, err) + } + + // The shadow link is the agent's: a real entry there is a conflict, never removed. + link := filepath.Join(view.Dir, filepath.Dir(shadowExtracted)) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(link, "policies"), 0o755); err != nil { + t.Fatal(err) + } + var conflict *policyview.LinkConflictError + if err := h.rc.activateInline(active); !errors.As(err, &conflict) { + t.Fatalf("activation must fail with a link conflict, got %v", err) + } + if _, err := active.runtime.pluginWorkDir("ssh"); !errors.As(err, &conflict) { + t.Fatalf("the run must fail with a link conflict, got %v", err) + } + if info, err := os.Lstat(link); err != nil || !info.IsDir() { + t.Fatalf("the conflicting entry must be left alone: %v", err) + } +} diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index ee24358..20f39d9 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -111,6 +111,14 @@ view (artifact uploads, source props). R82's `policy_id` remains the fallback wh (absolute `extends`, a plugin loading the source and the bundle together), and the R79 gate only applies there. Risk: a plugin that relies on its working directory sees the view (mirrored, so reads and writes inside existing directories still reach the agent's; new top-level files stay in the view). +Plugin contract (rule 1): plugins must not rely on creating new files relative to their working directory (use +absolute paths or `os.TempDir()`); such entries stay in the plugin's view and are removed with it. A real +(non-symlink) entry in a view is plugin-owned: when the agent's working directory later gets the same name, the +view keeps the plugin's entry instead of mirroring the agent's, warns once per view and name, and never fails a run +or an activation. Only the shadow link is agent-owned: a real entry at its path is a conflict that fails activation +and the run with a clear error and is not removed (falling back to R82 there would need the bundle re-materialized +with continuity `policy_id`s, which is decided before materializing, so the previous configuration keeps running +instead). View GC removes whole views, plugin-owned entries included, and never follows links. ### Stable inline paths (R67) diff --git a/docs/configuration.md b/docs/configuration.md index 0aa51e8..60361e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -321,6 +321,14 @@ policy_bundles: path of the plugin cannot be represented in a view (e.g. a single-component path such as `policies`), or without symlinks. A plugin running in a view sees its working directory as the view: files it creates there (rather than through a mirrored directory) stay in the view and are removed with it. + **Plugin contract:** plugins must not rely on creating new files or directories relative to their working + directory; use absolute paths or `os.TempDir()`. Such entries are the plugin's (rule 1): they stay in the plugin's + view and are removed with it. If the agent's working directory later gets an entry with the same name, the plugin + keeps its own (and does not see the agent's); the agent logs one warning per view and name, and never fails the + plugin's run or the configuration's activation over it. The one exception is the shadowed path's link itself (the + parent of the vendor path), which is the agent's: a real entry there means the plugin replaced the link, so + activation and the plugin's run fail with an error naming it (the previous configuration keeps running), and the + agent does not remove it; deleting the view directory rebuilds it. - **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any `.rego` file fails with `download-failed`. - **Where bundles live (R67, R82).** Inline bundles are never downloaded. Each revision of a bundle is a write-once diff --git a/internal/policyview/policyview.go b/internal/policyview/policyview.go index c814960..b1976ac 100644 --- a/internal/policyview/policyview.go +++ b/internal/policyview/policyview.go @@ -21,6 +21,15 @@ // it does for the agent (reading and writing through the mirrored links), except new // entries the plugin creates directly in a view directory, which stay in the view. // +// Ownership (rule 1): the view directories and the shadow links are the agent's; any real +// (non-symlink) entry Ensure finds where it would put a mirror link was created by the +// plugin and is the plugin's. Ensure keeps it untouched (the plugin keeps seeing its own +// entry rather than the working directory's), warns once per view and name, and carries +// on: a plugin-owned entry never fails a run. A real entry at a shadow link's own path is a +// genuine conflict (the plugin would not see the bundle there), and Ensure fails with a +// *LinkConflictError. Plugin-owned entries go away with their view (GC), which never +// follows links. +// // A view is content-addressed by its links and base: a new bundle revision is a new view, so // nothing is ever swapped under a running plugin. Ensure is idempotent and only adds missing // mirror links, so it can run before every plugin run. @@ -38,6 +47,8 @@ import ( "regexp" "slices" "strings" + "sync" + "sync/atomic" ) // TreeDir is the last component a shadowed path must have: the inline store keeps each @@ -54,8 +65,28 @@ type View struct { // Links maps a slash-separated path relative to the view to the absolute directory it // links to. Links map[string]string + // Warn, when set, receives Ensure's warnings as a message and key/value pairs (an + // hclog.Logger's Warn fits). Each plugin-owned entry is reported once per process. + Warn func(msg string, args ...interface{}) +} + +// LinkConflictError is Ensure's error when a shadow link's path holds an entry that is not +// a symlink: only the plugin can have put it there, and the plugin would not see the +// bundle at its policy path. +type LinkConflictError struct { + // View is the view directory, Link the slash-separated path relative to it. + View, Link string } +func (e *LinkConflictError) Error() string { + return fmt.Sprintf("view %s: the shadowed policy path's link %s holds an entry the plugin created (not a symlink); "+ + "the agent does not remove plugin-owned entries: delete %s (the view is rebuilt) or stop the plugin replacing it", + e.View, e.Link, filepath.Join(e.View, filepath.FromSlash(e.Link))) +} + +// warned holds the "\x00" of the plugin-owned entries already warned about. +var warned sync.Map + // Shadowable reports whether a plugin path can be shadowed: a relative path, inside the // working directory, with a parent below it, whose last component is TreeDir (every OCI // source; a local source only when laid out the same way). @@ -176,8 +207,10 @@ func DirFor(root, plugin, base string, links map[string]string) string { } // Ensure creates the view: its directories, its links, and the mirror links of the base's -// entries. It never removes or changes an entry that is already right, so it is safe to run -// while a plugin uses the view. +// entries. It never removes or changes an entry that is already right, nor a plugin-owned +// one (a real entry where a mirror link would go, see the package doc), so it is safe to +// run while a plugin uses the view. It fails with a *LinkConflictError when a shadow link's +// path holds a real entry. func (v View) Ensure() error { if !filepath.IsAbs(v.Dir) || !filepath.IsAbs(v.Base) { return fmt.Errorf("view %s: the view and base directories must be absolute", v.Dir) @@ -190,7 +223,11 @@ func (v View) Ensure() error { } } for _, link := range sortedKeys(v.Links) { - if err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(link)), v.Links[link]); err != nil { + err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(link)), v.Links[link]) + if errors.Is(err, errNotSymlink) { + err = &LinkConflictError{View: v.Dir, Link: link} + } + if err != nil { errs = append(errs, err) } } @@ -215,7 +252,12 @@ func (v View) Ensure() error { if contains(target, v.Dir) { continue // never mirror a directory that holds the view itself } - if err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(child)), target); err != nil { + err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(child)), target) + if errors.Is(err, errNotSymlink) { + v.pluginOwned(child, target) + continue + } + if err != nil { errs = append(errs, err) } } @@ -226,6 +268,20 @@ func (v View) Ensure() error { return nil } +// pluginOwned warns, once per view and name, that the plugin's own entry name hides the +// working directory's entry target in the view. +func (v View) pluginOwned(name, target string) { + if v.Warn == nil { + return + } + if _, seen := warned.LoadOrStore(v.Dir+"\x00"+name, struct{}{}); seen { + return + } + v.Warn("Plugin created an entry in its working directory (view) that the agent's working directory now also has; "+ + "the plugin keeps its own, and does not see the agent's (plugins should not create files relative to their working directory)", + "view", v.Dir, "path", name, "hidden", target) +} + // Resolve returns the path the plugin opens for pluginPath, as seen from outside the view: // relative paths under the view, absolute ones unchanged. func (v View) Resolve(pluginPath string) string { @@ -244,16 +300,23 @@ func contains(dir, p string) bool { return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } +// errNotSymlink is ensureSymlink's error when path holds something that is not a symlink, +// which it never touches. +var errNotSymlink = errors.New("exists and is not a symlink") + +var tmpLinks atomic.Uint64 + // ensureSymlink makes path a symlink to target: a no-op when it already is, a new link, or -// an atomic replacement (temporary link renamed over it) of a link to something else. +// an atomic replacement (temporary link renamed over it) of a link to something else. It +// never removes or replaces anything but a symlink: a real entry at path is errNotSymlink. func ensureSymlink(path, target string) error { cur, err := os.Readlink(path) switch { case err == nil && cur == target: return nil case err == nil: - tmp := path + ".tmp-link" - _ = os.Remove(tmp) + // A name of our own, so a failure never removes an entry someone else made. + tmp := fmt.Sprintf("%s.tmp-link-%d-%d", path, os.Getpid(), tmpLinks.Add(1)) if err := os.Symlink(target, tmp); err != nil { return err } @@ -264,16 +327,24 @@ func ensureSymlink(path, target string) error { return nil } if _, statErr := os.Lstat(path); statErr == nil { - return fmt.Errorf("%s exists and is not a symlink", path) + return fmt.Errorf("%s %w", path, errNotSymlink) } - if err := os.Symlink(target, path); err != nil && !os.IsExist(err) { - return err + if err := os.Symlink(target, path); err != nil { + if !os.IsExist(err) { + return err + } + // Created meanwhile: a link (another Ensure) is fine, anything else is not ours. + if info, statErr := os.Lstat(path); statErr == nil && info.Mode()&os.ModeSymlink == 0 { + return fmt.Errorf("%s %w", path, errNotSymlink) + } } return nil } // GC removes the views under root (//) that are not in keep (View.Dir -// values). Removing a view never follows its links. +// values), with every entry in them, plugin-owned ones included. It never follows a link: +// a symlinked plugin directory is skipped, a symlinked view is removed as a link, and +// os.RemoveAll removes the links inside a view, not what they point to. func GC(root string, keep map[string]struct{}) error { plugins, err := os.ReadDir(root) if errors.Is(err, os.ErrNotExist) { @@ -300,7 +371,11 @@ func GC(root string, keep map[string]struct{}) error { left++ continue } - errs = append(errs, os.RemoveAll(dir)) + err := os.RemoveAll(dir) + if err == nil { + forgetWarnings(dir) + } + errs = append(errs, err) } if left == 0 { _ = os.Remove(pluginDir) @@ -309,6 +384,17 @@ func GC(root string, keep map[string]struct{}) error { return errors.Join(errs...) } +// forgetWarnings drops the warnings recorded for view dir, which is gone: a view rebuilt at +// the same directory warns again. +func forgetWarnings(dir string) { + warned.Range(func(k, _ any) bool { + if key, _ := k.(string); strings.HasPrefix(key, dir+"\x00") { + warned.Delete(k) + } + return true + }) +} + func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for k := range m { diff --git a/internal/policyview/policyview_test.go b/internal/policyview/policyview_test.go index e5b48bd..e2fc53c 100644 --- a/internal/policyview/policyview_test.go +++ b/internal/policyview/policyview_test.go @@ -1,8 +1,11 @@ package policyview import ( + "fmt" "os" "path/filepath" + "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -122,3 +125,211 @@ func TestEnsureNeverMirrorsTheViewIntoItself(t *testing.T) { _, err := os.Lstat(filepath.Join(v.Dir, "state")) assert.True(t, os.IsNotExist(err), "the directory holding the view is not mirrored") } + +// warnings records View.Warn calls. +type warnings struct { + mu sync.Mutex + logs []string +} + +func (w *warnings) warn(msg string, args ...interface{}) { + w.mu.Lock() + defer w.mu.Unlock() + w.logs = append(w.logs, fmt.Sprint(append([]interface{}{msg}, args...)...)) +} + +func (w *warnings) count() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.logs) +} + +// shadowedView is a view of base with the bundle tree target linked at vendor/. +func shadowedView(t *testing.T, base string, w *warnings) View { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(base, "vendor", "policies"), 0o755)) + target := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(target, "policies"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(target, "policies", "bundle.rego"), []byte("bundle"), 0o644)) + links := map[string]string{"vendor": target} + v := View{Dir: DirFor(filepath.Join(t.TempDir(), "views"), "custodian", base, links), Base: base, Links: links} + if w != nil { + v.Warn = w.warn + } + if err := v.Ensure(); err != nil { + if os.IsPermission(err) { + t.Skip(err) + } + require.NoError(t, err) + } + return v +} + +// TestEnsure_PluginOwnedEntriesAreKept (rule 1): a plugin that creates a directory relative to +// its working directory (cloud-custodian's debug-standardized-payloads) creates it in its view; +// when the agent's working directory later gets an entry of the same name, the plugin keeps +// its own, Ensure warns once and succeeds. +func TestEnsure_PluginOwnedEntriesAreKept(t *testing.T) { + base := t.TempDir() + w := &warnings{} + v := shadowedView(t, base, w) + + // The plugin creates a directory and a file in its working directory. + owned := filepath.Join(v.Dir, "debug-standardized-payloads") + require.NoError(t, os.MkdirAll(owned, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(owned, "payload.json"), []byte("plugin"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(v.Dir, "out.log"), []byte("plugin log"), 0o644)) + require.NoError(t, v.Ensure(), "entries only the view has are left alone") + assert.Zero(t, w.count()) + + // Later the agent's working directory gets the same names. + require.NoError(t, os.MkdirAll(filepath.Join(base, "debug-standardized-payloads"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(base, "debug-standardized-payloads", "agent.json"), []byte("agent"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(base, "out.log"), []byte("agent log"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(base, "fresh.txt"), []byte("fresh"), 0o644)) + + for range 3 { + require.NoError(t, v.Ensure(), "a plugin-owned entry never fails Ensure") + } + assert.Equal(t, 2, w.count(), "one warning per plugin-owned name, not one per Ensure: %v", w.logs) + assert.Contains(t, strings.Join(w.logs, "\n"), "debug-standardized-payloads") + + info, err := os.Lstat(owned) + require.NoError(t, err) + assert.True(t, info.IsDir() && info.Mode()&os.ModeSymlink == 0, "the plugin's directory is not replaced by a mirror link") + raw, err := os.ReadFile(filepath.Join(owned, "payload.json")) + require.NoError(t, err) + assert.Equal(t, "plugin", string(raw)) + _, err = os.Stat(filepath.Join(owned, "agent.json")) + assert.True(t, os.IsNotExist(err), "the agent's entry is hidden from the plugin") + raw, err = os.ReadFile(filepath.Join(v.Dir, "out.log")) + require.NoError(t, err) + assert.Equal(t, "plugin log", string(raw)) + raw, err = os.ReadFile(filepath.Join(base, "out.log")) + require.NoError(t, err) + assert.Equal(t, "agent log", string(raw), "the agent's entry is untouched") + + // Other entries are still mirrored, and the shadowed path is still the bundle. + raw, err = os.ReadFile(filepath.Join(v.Dir, "fresh.txt")) + require.NoError(t, err) + assert.Equal(t, "fresh", string(raw)) + raw, err = os.ReadFile(filepath.Join(v.Dir, "vendor", "policies", "bundle.rego")) + require.NoError(t, err) + assert.Equal(t, "bundle", string(raw)) + + // A view without a Warn hook behaves the same, silently. + silent := v + silent.Warn = nil + require.NoError(t, silent.Ensure()) +} + +// TestEnsure_PluginOwnedEntryInANestedViewDirectory: the same holds below the view root. +func TestEnsure_PluginOwnedEntryInANestedViewDirectory(t *testing.T) { + base := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(base, "a", "vendor", "policies"), 0o755)) + target := t.TempDir() + links := map[string]string{"a/vendor": target} + w := &warnings{} + v := View{Dir: DirFor(filepath.Join(t.TempDir(), "views"), "p", base, links), Base: base, Links: links, Warn: w.warn} + require.NoError(t, v.Ensure()) + require.NoError(t, os.WriteFile(filepath.Join(v.Dir, "a", "cache"), []byte("plugin"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(base, "a", "cache"), []byte("agent"), 0o644)) + require.NoError(t, v.Ensure()) + require.NoError(t, v.Ensure()) + assert.Equal(t, 1, w.count()) + raw, err := os.ReadFile(filepath.Join(v.Dir, "a", "cache")) + require.NoError(t, err) + assert.Equal(t, "plugin", string(raw)) +} + +// TestEnsure_ConflictAtTheShadowLink: the shadow link is the agent's; a real entry at its +// path (the plugin removed the link and created its own) is a clear error, and Ensure leaves +// the entry alone. +func TestEnsure_ConflictAtTheShadowLink(t *testing.T) { + base := t.TempDir() + v := shadowedView(t, base, &warnings{}) + link := filepath.Join(v.Dir, "vendor") + require.NoError(t, os.Remove(link)) + require.NoError(t, os.MkdirAll(filepath.Join(link, "policies"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(link, "policies", "mine.rego"), []byte("plugin"), 0o644)) + + err := v.Ensure() + var conflict *LinkConflictError + require.ErrorAs(t, err, &conflict) + assert.Equal(t, LinkConflictError{View: v.Dir, Link: "vendor"}, *conflict) + assert.Contains(t, err.Error(), "shadowed policy path") + raw, rerr := os.ReadFile(filepath.Join(link, "policies", "mine.rego")) + require.NoError(t, rerr, "the conflicting entry is not removed") + assert.Equal(t, "plugin", string(raw)) + + // Removing the view (GC, or by hand) rebuilds it cleanly. + require.NoError(t, os.RemoveAll(v.Dir)) + require.NoError(t, v.Ensure()) + raw, rerr = os.ReadFile(filepath.Join(link, "policies", "bundle.rego")) + require.NoError(t, rerr) + assert.Equal(t, "bundle", string(raw)) +} + +// TestEnsure_ReplacesAStaleMirrorLink: a mirror link to something else is the agent's and is +// replaced atomically, leaving no temporary link behind. +func TestEnsure_ReplacesAStaleMirrorLink(t *testing.T) { + base := t.TempDir() + v := shadowedView(t, base, nil) + require.NoError(t, os.WriteFile(filepath.Join(base, "cfg"), []byte("cfg"), 0o644)) + require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(v.Dir, "cfg"))) + require.NoError(t, v.Ensure()) + cur, err := os.Readlink(filepath.Join(v.Dir, "cfg")) + require.NoError(t, err) + assert.Equal(t, filepath.Join(base, "cfg"), cur) + entries, err := os.ReadDir(v.Dir) + require.NoError(t, err) + for _, e := range entries { + assert.NotContains(t, e.Name(), ".tmp-link", "no temporary link left behind") + } +} + +// TestGC_RemovesPluginOwnedEntriesWithoutFollowingLinks: GC removes a whole view, plugin-owned +// entries included, but never what a link (a mirror link, the shadow link, or a link the +// plugin made itself) points to; and a view rebuilt in the same place warns again. +func TestGC_RemovesPluginOwnedEntriesWithoutFollowingLinks(t *testing.T) { + base := t.TempDir() + w := &warnings{} + v := shadowedView(t, base, w) + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(outside, "keep.txt"), []byte("keep"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(base, "agent.txt"), []byte("agent"), 0o644)) + owned := filepath.Join(v.Dir, "debug-standardized-payloads") + require.NoError(t, os.MkdirAll(owned, 0o755)) + require.NoError(t, os.Symlink(outside, filepath.Join(owned, "elsewhere"))) + require.NoError(t, os.MkdirAll(filepath.Join(base, "debug-standardized-payloads"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(base, "debug-standardized-payloads", "agent.json"), []byte("agent"), 0o644)) + require.NoError(t, v.Ensure()) + require.Equal(t, 1, w.count()) + + // A symlinked plugin directory under the root is not followed either. + root := filepath.Dir(filepath.Dir(v.Dir)) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "linked"))) + + require.NoError(t, GC(root, map[string]struct{}{})) + _, err := os.Lstat(v.Dir) + assert.True(t, os.IsNotExist(err), "the view, plugin-owned entries included, is removed") + for _, p := range []string{ + filepath.Join(outside, "keep.txt"), + filepath.Join(base, "agent.txt"), + filepath.Join(base, "debug-standardized-payloads", "agent.json"), + filepath.Join(base, "vendor", "policies"), + filepath.Join(v.Links["vendor"], "policies", "bundle.rego"), + } { + _, err := os.Stat(p) + assert.NoError(t, err, "GC must not follow links: %s", p) + } + _, err = os.Lstat(filepath.Join(root, "linked")) + assert.NoError(t, err, "a symlinked plugin directory is skipped") + + // Rebuilt in the same place, the view warns again for a new plugin-owned entry. + require.NoError(t, v.Ensure()) + require.NoError(t, os.Remove(filepath.Join(v.Dir, "debug-standardized-payloads"))) + require.NoError(t, os.MkdirAll(owned, 0o755)) + require.NoError(t, v.Ensure()) + assert.Equal(t, 2, w.count()) +} From da5360b3066581c3fcabf004708c36e8d61fb7cf Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:49:52 -0300 Subject: [PATCH 34/47] fix: address review feedback (R88-R91) - M1: a plugin view's Ensure error is logged at activation; only that plugin's runs fail (pluginWorkDir), never the configuration. - M2: inline links live under .compliance-framework/policies/_inline. - R88: drop the R82 continuity policy_id, the v0.9.0 gate, plugins[].inline-policies and the plugin-path report fields; keep authored policy_id, R75 checks, the set-form error and the policy_id warning (pluginlib.MinPolicyID). Unshadowed bundles warn policy-stream-forked for their inherited modules. - Shrink: policyeval.MergeData/StaticPolicyIDs/RuleName, regocheck.ToPolicyError, slices.Sorted(maps.Keys), no layout migration. - Tests: view conflict blast radius, planShadowing drops, pluginCommand, table-driven compat. Docs no longer call shadowing a prototype. - Pin api 52f0c63a6690 (api#465). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 +- cmd/agent.go | 38 +-- cmd/compat.go | 76 ++--- cmd/compat_test.go | 307 +++++++++----------- cmd/config.go | 13 +- cmd/identity.go | 6 +- cmd/inline.go | 98 +++---- cmd/inline_test.go | 234 +-------------- cmd/reconciler.go | 11 +- cmd/remote_test.go | 2 +- cmd/shadow.go | 37 +-- cmd/shadow_test.go | 267 ++++++++++++----- docs/adr/0003-remote-config-overlay.md | 65 +++-- docs/configuration.md | 132 ++++----- docs/policy_artifacts.md | 7 +- go.mod | 2 +- go.sum | 4 +- internal/inlinepolicy/activate_test.go | 38 --- internal/inlinepolicy/check.go | 26 +- internal/inlinepolicy/continuity_test.go | 320 --------------------- internal/inlinepolicy/contract.go | 82 +++--- internal/inlinepolicy/contract_test.go | 17 +- internal/inlinepolicy/identity.go | 245 +++------------- internal/inlinepolicy/identity_test.go | 44 +-- internal/inlinepolicy/inlinepolicy_test.go | 65 +---- internal/inlinepolicy/materialize.go | 124 +++----- internal/inlinepolicy/streams_test.go | 280 ++++++++++++++++++ internal/pluginlib/pluginlib.go | 14 +- internal/pluginlib/pluginlib_test.go | 40 +-- internal/policyview/policyview.go | 63 ++-- internal/policyview/policyview_test.go | 6 +- 31 files changed, 1014 insertions(+), 1658 deletions(-) delete mode 100644 internal/inlinepolicy/continuity_test.go create mode 100644 internal/inlinepolicy/streams_test.go diff --git a/AGENTS.md b/AGENTS.md index c9925dd..e13dd11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,10 +117,11 @@ change here must keep working with them. - **Evidence identity.** `policy-manager`'s `newEvidence` seed is every evidence stream's UUID, and plugins in the field compute it. Never change the seed of a policy without `policy_id`; `policy_id` changes it only through the API's `policyeval.SeedPath`. The golden test in `policy-manager/policy_id_test.go` pins the old UUIDs. -- **Plugin library gate.** Inline policies need a plugin built on agent ≥ `pluginlib.MinInlinePolicy` - (`internal/pluginlib`, v0.9.0, R81). Set it to the first release that ships `policy_id` seeding (v0.8.0 and - v0.8.1 shipped without it); update it before tagging if agent#95 ships in a different release. Versions compare as - semver, so pre-releases of the minimum (`v0.9.0-rc*`) are older and unsupported. +- **Plugin library checks.** Inline policies work with every plugin build (path shadowing keeps vendor evidence + streams). Only two things depend on the plugin's agent library (`internal/pluginlib`): an overlay-introduced + set-form `violation contains` is rejected below `MinViolationSet` (v0.7.1), and an authored `policy_id` warns below + `MinPolicyID` (v0.9.0, the first release with `policy_id` seeding; update it before tagging if agent#95 ships in a + different release). Versions compare as semver, so pre-releases of a minimum are older. - **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/cmd/agent.go b/cmd/agent.go index b034518..f34ef9b 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "maps" "math/rand" "net/http" "os" @@ -17,6 +18,7 @@ import ( "path/filepath" "reflect" "runtime" + "slices" "sort" "strconv" "strings" @@ -95,8 +97,9 @@ type agentConfig struct { AgentEvidence *agentEvidenceConfig `mapstructure:"agent_evidence"` // inlinePolicyDirs maps "inline:" policy entries to the path plugins receive: the - // bundle's stable path (.compliance-framework/policies/inline//policies, R82), which - // the reconciler points at inlineTrees before each run (R67). + // extends source's path for a shadowed bundle (resolved in the plugin's view), else the + // bundle's stable path (.compliance-framework/policies/_inline//policies), which the + // reconciler points at inlineTrees before each run (R67). inlinePolicyDirs map[string]string // inlineTrees maps "inline:" policy entries to their materialized, content-addressed // tree (inlinepolicy.Materialized.Dir). @@ -1955,18 +1958,24 @@ func safePluginErrorFilename(pluginName string) string { return b.String() + "-error.txt" } -func (ar *AgentRunner) getRunnerInstance(logger hclog.Logger, path string, protocolVersion int32, workDir string) (runner.RunnerV2, func(), error) { - // We're a host! Start by launching the plugin process. The binary is started by its - // absolute path: a relative one would resolve against workDir. +// pluginCommand is the command that starts the plugin binary at path in workDir ("" for the +// agent's own working directory). The binary is started by its absolute path, since a +// relative one would resolve against workDir: a plugin that receives a shadowed inline +// bundle runs in its view (path shadowing). Plugins get the host environment minus the +// agent's own API credentials (R26); go-plugin would otherwise append the whole environment. +func pluginCommand(path, workDir string) *exec.Cmd { if abs, err := filepath.Abs(path); err == nil { path = abs } cmd := exec.Command(path) - // A plugin that receives a shadowed inline bundle runs in its view (path shadowing). cmd.Dir = workDir - // Plugins get the host environment minus the agent's own API credentials (R26); go-plugin - // would otherwise append the whole environment. cmd.Env = pluginEnviron(os.Environ()) + return cmd +} + +func (ar *AgentRunner) getRunnerInstance(logger hclog.Logger, path string, protocolVersion int32, workDir string) (runner.RunnerV2, func(), error) { + // We're a host! Start by launching the plugin process. + cmd := pluginCommand(path, workDir) client := plugin.NewClient(&plugin.ClientConfig{ HandshakeConfig: runner.HandshakeConfig, Plugins: runner.PluginMap, @@ -2110,13 +2119,13 @@ func (ar *AgentRunner) Prefetch(ctx context.Context, cfg *agentConfig) error { policySources[string(policy)] = struct{}{} } } - for _, source := range sortedSetKeys(pluginSources) { + 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 sortedSetKeys(policySources) { + 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} } @@ -2183,15 +2192,6 @@ func (ar *AgentRunner) downloadPolicy(ctx context.Context, source string, logger return ar.download(ctx, source, AgentPolicyDir, "policies", "", logger) } -func sortedSetKeys(set map[string]struct{}) []string { - keys := make([]string, 0, len(set)) - for k := range set { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - func platformDownloadKey(platform v1.Platform) string { return strings.Join([]string{platform.OS, platform.Architecture, platform.Variant}, "/") } diff --git a/cmd/compat.go b/cmd/compat.go index 7673937..9b10bc5 100644 --- a/cmd/compat.go +++ b/cmd/compat.go @@ -3,56 +3,36 @@ package cmd import ( "context" "fmt" + "maps" + "slices" "github.com/compliance-framework/agent/internal/inlinepolicy" "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/api/pkg/agentconfig" ) -// Plugin compatibility (R76, R79, relaxed by path shadowing). A plugin evaluates policies -// with the policy-manager it embeds, so what it can do with an inline bundle depends on the -// agent library it was built with, which the agent reads from the plugin binary's build info. -// With path shadowing (shadow.go) a plugin keeps the vendor's evidence streams of a bundle -// that extends a relative (OCI) source whatever library it was built with, so the R79 gate -// only remains where continuity cannot be guaranteed otherwise: +// Plugin compatibility (R76, R88). A plugin evaluates policies with the policy-manager it +// embeds, so what it can do with an inline bundle depends on the agent library it was built +// with, which the agent reads from the plugin binary's build info. Path shadowing +// (shadow.go) keeps vendor evidence streams with any library, so only two things depend on +// it: // -// - a bundle that extends a source and is not shadowed (an absolute local extends path, -// a plugin that also loads the source, no symlinks) keeps the vendor streams only -// through the continuity policy_id (R82), which needs pluginlib.MinInlinePolicy (R74). -// For an older library an overlay that introduces it is rejected -// (plugin-lib-inline-unsupported), so the last good configuration keeps running; // - a set-form violation (`violation contains ...`) crashes plugins older than -// pluginlib.MinViolationSet: rejected when the overlay introduces it +// pluginlib.MinViolationSet: an error when the overlay introduces it // (plugin-lib-violation-set-unsupported); -// - a policy_id an authored module declares is ignored by an older library, so the -// module's stream is path-based: a warning (plugin-lib-policy-id-unsupported); -// - inline bundles from the config file only warn (R34), and so does a library whose -// version is unknown (a local or replaced build, or no build info). +// - a policy_id an authored module declares is ignored by plugins older than +// pluginlib.MinPolicyID, so the module's stream is path-based: a warning +// (plugin-lib-policy-id-unsupported). // -// plugins[].inline-policies now reads: supported (the library version is known, so the agent -// checks each bundle against it as above) or unknown; unsupported is no longer reported. - -// Shorter names for the plugins[].inline-policies values (R79). -const ( - inlinePoliciesSupported = agentconfig.InlinePoliciesSupported - inlinePoliciesUnsupported = agentconfig.InlinePoliciesUnsupported - inlinePoliciesUnknown = agentconfig.InlinePoliciesUnknown -) +// Inline bundles from the config file only warn (R34), and so does a library whose version +// is unknown (a local or replaced build, or no build info). The plugins report carries each +// plugin's lib-version. // 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) -// inlineSupport classifies a plugin's agent library version for inline policies: with path -// shadowing every known library takes inline bundles (each bundle is checked against it). -func inlineSupport(version string) string { - if _, known := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy); !known { - return inlinePoliciesUnknown - } - return inlinePoliciesSupported -} - -// pluginCompatibility returns the R76/R79 problems of the plugins of runtime that use inline +// pluginCompatibility returns the R76 problems of the plugins of runtime that use inline // bundles, and the plugins report. origin tells overlay-introduced problems apart. Without a // pluginLib function (tests) it checks and reports nothing. func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentConfig, materialized map[string]*inlinepolicy.Materialized, origin policyOrigin) ([]agentconfig.PolicyError, []agentconfig.PluginReport) { @@ -61,7 +41,7 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon } var problems []agentconfig.PolicyError var reports []agentconfig.PluginReport - for _, name := range sortedMapKeys(runtime.Plugins) { + for _, name := range slices.Sorted(maps.Keys(runtime.Plugins)) { p := runtime.Plugins[name] version, err := rc.pluginLib(ctx, p.Source) if err != nil { @@ -70,9 +50,8 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon rc.logger.Warn("Could not read the agent library version of a plugin; treating it as unknown", "plugin", name, "source", p.Source, "error", err) } } - support := inlineSupport(version) - reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version, InlinePolicies: support}) - if ok, _ := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy); ok { + reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version}) + if ok, _ := pluginlib.AtLeast(version, pluginlib.MinPolicyID); ok { continue // policy_id and set-form violations both work } @@ -97,14 +76,8 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon return problems, reports } -// needsPolicyID reports whether bundle m keeps vendor evidence streams only through the -// continuity policy_id the agent appended (R82): it extends a source and is not shadowed. -func needsPolicyID(m *inlinepolicy.Materialized) bool { - return m.Extends != nil && !m.Shadowed && len(m.Continued) > 0 -} - // libProblems are the problems of one plugin whose library is older than -// pluginlib.MinInlinePolicy or unknown. overlay is whether the overlay brought the plugin and +// pluginlib.MinPolicyID or unknown. overlay is whether the overlay brought the plugin and // these bundles together; only then is a known incompatibility an error. func libProblems(plugin, version string, bundles []*inlinepolicy.Materialized, overlay bool) []agentconfig.PolicyError { var out []agentconfig.PolicyError @@ -112,7 +85,7 @@ func libProblems(plugin, version string, bundles []*inlinepolicy.Materialized, o if lib == "" { lib = "unknown" } - idOK, known := pluginlib.AtLeast(version, pluginlib.MinInlinePolicy) + idOK, known := pluginlib.AtLeast(version, pluginlib.MinPolicyID) setOK, setKnown := pluginlib.AtLeast(version, pluginlib.MinViolationSet) severity := func(known bool) string { if known && overlay { @@ -125,15 +98,6 @@ func libProblems(plugin, version string, bundles []*inlinepolicy.Materialized, o unknownWhy = fmt.Sprintf("%s has no release before it", version) } for _, m := range bundles { - if !idOK && needsPolicyID(m) { - msg := fmt.Sprintf("plugin %s (agent lib %s) cannot keep the vendor evidence streams of bundle %s: it extends %s at %s, which cannot be shadowed, so its inherited and overridden modules continue the vendor streams only through policy_id, which needs agent ≥ %s; upgrade the plugin, or extend a relative (OCI) source the plugin does not also load", - plugin, lib, m.Name, m.Extends.Source, m.Extends.PluginPath, pluginlib.MinInlinePolicy) - if !known { - msg = fmt.Sprintf("plugin %s: its agent library version %s, so the agent cannot tell whether it honours the policy_id that continues the vendor streams of bundle %s (it extends %s at %s, which cannot be shadowed); plugins built on agent < %s ignore it and start new streams", - plugin, unknownWhy, m.Name, m.Extends.Source, m.Extends.PluginPath, pluginlib.MinInlinePolicy) - } - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Severity: severity(known), Code: agentconfig.PolicyCodePluginLibInlineUnsupported, Message: msg}) - } if !setOK { for _, s := range m.SetViolations { msg := fmt.Sprintf("plugin %s (agent lib %s) cannot evaluate violation as a set (`violation contains ...` needs agent ≥ %s) and would crash; use `violation[{...}] if { … }`", diff --git a/cmd/compat_test.go b/cmd/compat_test.go index ab0a79a..7fd80e0 100644 --- a/cmd/compat_test.go +++ b/cmd/compat_test.go @@ -6,14 +6,11 @@ import ( "testing" "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/api/pkg/agentconfig" ) -// Plugin compatibility (R76, R79, relaxed by path shadowing): the plugin's agent library -// decides what it can do with an inline bundle. The harness's vendor is an absolute path, -// which cannot be shadowed, so these bundles need policy_id (shadow_test.go covers -// shadowed bundles). +// Plugin compatibility (R76, R88): the plugin's agent library decides whether it can +// evaluate set-form violations, and whether it honours an authored policy_id. // withPluginLib makes the harness's plugins report version as their agent library. func withPluginLib(h *remoteHarness, version string) { @@ -21,7 +18,7 @@ func withPluginLib(h *remoteHarness, version string) { } // inlineOverlay changes the inline bundle ssh, which plugin ssh uses (an overlay-introduced -// inline policy). +// inline policy), with a set-form violation. const inlineOverlay = `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\ntitle := \"extra v2\"\n\nviolation contains {\"id\": \"x\"} if input.max > data.max\n"}}}}` func policyErrorsWithCode(r agentconfig.Report, code string) []agentconfig.PolicyError { @@ -46,171 +43,149 @@ func rejectionErrors(r agentconfig.Report, code string) []agentconfig.PolicyErro return out } -func TestCompat_OldLibRejectsOverlayInlineBundle_R79(t *testing.T) { - h, _ := newInlineHarness(t) - withPluginLib(h, "v0.1.9-0.20250708121809-c5059c3efac8") - h.remote.publish(1, inlineOverlay) - active := mustStartup(t, h.rc) - if active.overlay != nil { - t.Fatal("an inline bundle for a plugin that cannot honour it must not be applied") - } - r := h.remote.lastReport(t) - if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors { - t.Fatalf("expected rejected/policy-errors, got %s/%s", r.Status, r.Reason) - } - gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported) - if len(gate) != 1 || gate[0].Bundle != "ssh" || - !strings.Contains(gate[0].Message, "plugin ssh (agent lib v0.1.9-0.20250708121809-c5059c3efac8) cannot keep the vendor evidence streams of bundle ssh") || - !strings.Contains(gate[0].Message, pluginlib.MinInlinePolicy) { - t.Fatalf("expected one plugin-lib-inline-unsupported error naming the plugin, its lib and the minimum, got %+v", r.PolicyErrors) - } - // The more specific set-form problem is named too, with its fix. - set := rejectionErrors(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported) - if len(set) != 1 || set[0].Path != "extra.rego" || set[0].Row == 0 || - !strings.Contains(set[0].Message, "violation[{...}] if") { - t.Fatalf("expected a located set-form violation error, got %+v", r.PolicyErrors) - } - // No authored policy_id, so no policy_id warning. - if got := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibPolicyIDUnsupported); len(got) != 0 { - t.Fatalf("no policy_id warning expected, got %+v", got) - } - // With path shadowing a known library is reported as supported; the gate is per bundle. - if len(r.Plugins) != 1 || r.Plugins[0].Name != "ssh" || r.Plugins[0].Source != "ghcr.io/compliance-framework/plugin-ssh:v1" || - r.Plugins[0].LibVersion != "v0.1.9-0.20250708121809-c5059c3efac8" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { - t.Fatalf("plugins report = %+v", r.Plugins) - } -} - -func TestCompat_AssigningInlineBundleToOldLibIsRejected_R79(t *testing.T) { - h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1"]`, 1)) - withPluginLib(h, "v0.7.2") - h.remote.publish(1, `{"plugins":{"ssh":{"policies":["inline:ssh"]}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay != nil || r.Status != agentconfig.StatusRejected { - t.Fatalf("assigning an inline bundle to a v0.7.2 plugin must be rejected, got %s/%s", r.Status, r.Reason) - } - if gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported); len(gate) != 1 { - t.Fatalf("expected the plugin-lib-inline-unsupported error, got %+v", r.PolicyErrors) - } - // v0.7.2 evaluates set-form violations: no set-form error. - if set := rejectionErrors(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported); len(set) != 0 { - t.Fatalf("no set-form error expected for v0.7.2, got %+v", set) - } -} - -func TestCompat_SupportedLibApplies_R79(t *testing.T) { - h, _ := newInlineHarness(t) - withPluginLib(h, "v0.9.0") - h.remote.publish(1, inlineOverlay) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay == nil || r.Status != agentconfig.StatusApplied { - t.Fatalf("a supported plugin must apply the revision, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - for _, code := range []string{agentconfig.PolicyCodePluginLibInlineUnsupported, agentconfig.PolicyCodePluginLibViolationSetUnsupported, agentconfig.PolicyCodePluginLibPolicyIDUnsupported} { - if got := policyErrorsWithCode(r, code); len(got) != 0 { - t.Fatalf("no %s expected for a supported plugin, got %+v", code, got) - } - } - if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "v0.9.0" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported { - t.Fatalf("plugins report = %+v", r.Plugins) - } -} - -func TestCompat_UnknownLibAppliesWithWarnings_R79(t *testing.T) { - // A local build with a replace (the RC stack's plugin-local-ssh) or "(devel)": the - // library version is unknown. - h, _ := newInlineHarness(t) - withPluginLib(h, "") - h.remote.publish(1, inlineOverlay) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay == nil || r.Status != agentconfig.StatusApplied { - t.Fatalf("an unknown library must not block, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - gate := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibInlineUnsupported) - if len(gate) != 1 || gate[0].Severity != agentconfig.SeverityWarning || !strings.Contains(gate[0].Message, "unknown") { - t.Fatalf("expected a plugin-lib-inline-unsupported warning, got %+v", r.PolicyErrors) - } - set := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported) - if len(set) != 1 || set[0].Severity != agentconfig.SeverityWarning { - t.Fatalf("expected a set-form warning, got %+v", r.PolicyErrors) - } - if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != "" || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesUnknown { - t.Fatalf("plugins report = %+v", r.Plugins) - } -} - -func TestCompat_FileInlineBundleOnOldLibWarns_R79(t *testing.T) { - h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, " title := \"extra\"\n", " policy_id := \"extra\"\n\n title := \"extra\"\n", 1)) - withPluginLib(h, "v0.7.2") - active := mustStartup(t, h.rc) - if active == nil { - t.Fatal("a file-defined inline bundle must still load") - } - r := h.remote.lastReport(t) - if r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable { - t.Fatalf("file-origin problems only warn, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - gate := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibInlineUnsupported) - if len(gate) != 1 || gate[0].Severity != agentconfig.SeverityWarning { - t.Fatalf("expected a plugin-lib-inline-unsupported warning, got %+v", r.PolicyErrors) - } - ids := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibPolicyIDUnsupported) - if len(ids) != 1 || ids[0].Severity != agentconfig.SeverityWarning || ids[0].Path != "extra.rego" { - t.Fatalf("expected a plugin-lib-policy-id-unsupported warning for the file bundle, got %+v", r.PolicyErrors) - } -} - -func TestLibProblemsNameAnUntaggedVersion(t *testing.T) { - m := &inlinepolicy.Materialized{Name: "ssh", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/v", PluginPath: "/v"}, Continued: map[string]string{"x.rego": "/v/x.rego"}} - got := libProblems("ssh", "v0.0.0-20261001110117-f88bde9ee37a", []*inlinepolicy.Materialized{m}, true) - if len(got) != 1 || got[0].Severity != agentconfig.SeverityWarning || !strings.Contains(got[0].Message, "v0.0.0-20261001110117-f88bde9ee37a has no release before it") { - t.Fatalf("an untagged build only warns and names its version, got %+v", got) +func TestCompat(t *testing.T) { + const ( + set = agentconfig.PolicyCodePluginLibViolationSetUnsupported + id = agentconfig.PolicyCodePluginLibPolicyIDUnsupported + ) + vendorPolicies := strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1"]`, 1) + withPolicyID := strings.Replace(inlineBaseConfig, " title := \"extra\"\n", " policy_id := \"extra\"\n\n title := \"extra\"\n", 1) + trusted := strings.Replace(inlineBaseConfig, "mode: apply_safe", "mode: apply_safe\n trusted_sources: [\"ghcr.io/compliance-framework/*\"]", 1) + for _, tc := range []struct { + name string + config string + libs map[string]string // plugin source -> lib version; "*" for any other + overlay string + applied bool + want map[string]string // code -> severity of its problems at extra.rego ("" = none) + }{ + { + name: "overlay set form, lib v0.1.9", libs: map[string]string{"*": oldLib}, overlay: inlineOverlay, + want: map[string]string{set: agentconfig.SeverityError}, + }, + { + name: "overlay set form, lib v0.9.0", libs: map[string]string{"*": "v0.9.0"}, overlay: inlineOverlay, applied: true, + }, + { + name: "overlay set form, unknown lib", libs: map[string]string{"*": ""}, overlay: inlineOverlay, applied: true, + want: map[string]string{set: agentconfig.SeverityWarning}, + }, + { + name: "overlay assigns a set-form bundle, lib v0.7.2", config: vendorPolicies, libs: map[string]string{"*": "v0.7.2"}, + overlay: `{"plugins":{"ssh":{"policies":["inline:ssh"]}}}`, applied: true, + }, + { + name: "overlay assigns a set-form bundle, lib v0.7.0", config: vendorPolicies, libs: map[string]string{"*": "v0.7.0"}, + overlay: `{"plugins":{"ssh":{"policies":["inline:ssh"]}}}`, + want: map[string]string{set: agentconfig.SeverityError}, + }, + { + name: "overlay moves the plugin to an old build", config: trusted, + libs: map[string]string{"ghcr.io/compliance-framework/plugin-ssh:v0": "v0.7.0", "*": "v0.9.0"}, + overlay: `{"plugins":{"ssh":{"source":"ghcr.io/compliance-framework/plugin-ssh:v0"}}}`, + want: map[string]string{set: agentconfig.SeverityError}, + }, + { + name: "file policy_id and set form, lib v0.7.0", config: withPolicyID, libs: map[string]string{"*": "v0.7.0"}, applied: true, + want: map[string]string{set: agentconfig.SeverityWarning, id: agentconfig.SeverityWarning}, + }, + { + name: "overlay policy_id, lib v0.8.1", libs: map[string]string{"*": "v0.8.1"}, applied: true, + overlay: `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\npolicy_id := \"extra\"\n\ntitle := \"extra v2\"\n\nviolation[{\"id\": \"x\"}] if input.max > data.max\n"}}}}`, + want: map[string]string{id: agentconfig.SeverityWarning}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + config := tc.config + if config == "" { + config = inlineBaseConfig + } + h, _ := newInlineHarnessWith(t, config) + lib := func(source string) string { + if v, ok := tc.libs[source]; ok { + return v + } + return tc.libs["*"] + } + h.rc.pluginLib = func(_ context.Context, source string) (string, error) { return lib(source), nil } + if tc.overlay != "" { + h.remote.publish(1, tc.overlay) + } + active := mustStartup(t, h.rc) + r := h.remote.lastReport(t) + switch { + case tc.overlay == "" && r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable: + t.Fatalf("the file configuration must load, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + case tc.overlay != "" && tc.applied && (active.overlay == nil || r.Status != agentconfig.StatusApplied): + t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + case !tc.applied && (active.overlay != nil || r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors): + t.Fatalf("expected rejected/policy-errors, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) + } + for _, code := range []string{set, id} { + got := policyErrorsWithCode(r, code) + if tc.want[code] == agentconfig.SeverityError { + got = rejectionErrors(r, code) + } + switch { + case tc.want[code] == "" && len(got) != 0: + t.Fatalf("no %s expected, got %+v", code, got) + case tc.want[code] == "": + case len(got) != 1 || got[0].Severity != tc.want[code] || got[0].Path != "extra.rego" || got[0].Row == 0 || !strings.Contains(got[0].Message, "plugin ssh"): + t.Fatalf("expected one located %s %s naming the plugin, got %+v", tc.want[code], code, r.PolicyErrors) + case code == set && !strings.Contains(got[0].Message, "violation[{...}] if"): + t.Fatalf("the set-form problem must name the fix: %s", got[0].Message) + } + } + if len(r.Plugins) != 1 || r.Plugins[0].Name != "ssh" || r.Plugins[0].LibVersion != lib(r.Plugins[0].Source) { + t.Fatalf("plugins report = %+v", r.Plugins) + } + }) } } -func TestInlineSupport(t *testing.T) { - for version, want := range map[string]string{ - "v0.9.0": inlinePoliciesSupported, - "v0.10.0": inlinePoliciesSupported, - "v0.9.1-0.20261001000000-abcdefabcdef": inlinePoliciesSupported, - // Path shadowing: every known library takes inline bundles (checked per bundle). - "v0.9.0-rc1": inlinePoliciesSupported, - "v0.8.1": inlinePoliciesSupported, - "v0.8.0": inlinePoliciesSupported, - "v0.8.0-rc4": inlinePoliciesSupported, - "v0.7.1": inlinePoliciesSupported, - oldLib: inlinePoliciesSupported, - "": inlinePoliciesUnknown, - "(devel)": inlinePoliciesUnknown, - "v0.0.0-20261001110117-f88bde9ee37a": inlinePoliciesUnknown, +// TestLibProblems: per plugin library and origin, for any bundle (shadowed or not). +func TestLibProblems(t *testing.T) { + set := []inlinepolicy.Site{{Path: "s.rego", Row: 1, Col: 1}} + ids := []inlinepolicy.Site{{Path: "i.rego", Row: 1, Col: 1}} + shadowed := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "oci"}, Shadowed: true, SetViolations: set, PolicyIDRules: ids} + absolute := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/abs"}, SetViolations: set, PolicyIDRules: ids} + plain := &inlinepolicy.Materialized{Name: "b"} + type want struct{ set, id string } // severity of each code, "" = none + for _, tc := range []struct { + name string + version string + m *inlinepolicy.Materialized + overlay bool + want want + }{ + {"nothing to check, v0.1.9", oldLib, plain, true, want{}}, + {"shadowed, v0.1.9", oldLib, shadowed, true, want{set: agentconfig.SeverityError, id: agentconfig.SeverityWarning}}, + {"not shadowed, v0.1.9", oldLib, absolute, true, want{set: agentconfig.SeverityError, id: agentconfig.SeverityWarning}}, + {"file, v0.1.9", oldLib, absolute, false, want{set: agentconfig.SeverityWarning, id: agentconfig.SeverityWarning}}, + {"v0.7.2", "v0.7.2", absolute, true, want{id: agentconfig.SeverityWarning}}, + {"v0.9.0-rc1", "v0.9.0-rc1", absolute, true, want{id: agentconfig.SeverityWarning}}, + {"unknown", "", absolute, true, want{set: agentconfig.SeverityWarning}}, } { - if got := inlineSupport(version); got != want { - t.Errorf("inlineSupport(%q) = %s, want %s", version, got, want) - } + t.Run(tc.name, func(t *testing.T) { + var got want + for _, e := range libProblems("ssh", tc.version, []*inlinepolicy.Materialized{tc.m}, tc.overlay) { + switch e.Code { + case agentconfig.PolicyCodePluginLibViolationSetUnsupported: + got.set = e.Severity + case agentconfig.PolicyCodePluginLibPolicyIDUnsupported: + got.id = e.Severity + default: + t.Fatalf("unexpected problem %+v", e) + } + } + if got != tc.want { + t.Fatalf("got %+v, want %+v", got, tc.want) + } + }) } -} -// TestCompat_OverlayMovingAnInlinePluginToAnOldBuildIsRejected_R79: the file gives the -// plugin an inline bundle; an overlay that switches the plugin to an older build introduces -// the incompatibility. -func TestCompat_OverlayMovingAnInlinePluginToAnOldBuildIsRejected_R79(t *testing.T) { - h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, "mode: apply_safe", "mode: apply_safe\n trusted_sources: [\"ghcr.io/compliance-framework/*\"]", 1)) - h.rc.pluginLib = func(_ context.Context, source string) (string, error) { - if source == "ghcr.io/compliance-framework/plugin-ssh:v0" { - return "v0.7.2", nil - } - return "v0.9.0", nil - } - h.remote.publish(1, `{"plugins":{"ssh":{"source":"ghcr.io/compliance-framework/plugin-ssh:v0"}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay != nil || r.Status != agentconfig.StatusRejected { - t.Fatalf("moving an inline plugin to an old build must be rejected, got %s/%s %s", r.Status, r.Reason, derefString(r.Error)) - } - if gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported); len(gate) != 1 || !strings.Contains(gate[0].Message, "v0.7.2") { - t.Fatalf("expected the plugin-lib-inline-unsupported error, got %+v", r.PolicyErrors) + untagged := libProblems("ssh", "v0.0.0-20261001110117-f88bde9ee37a", []*inlinepolicy.Materialized{absolute}, true) + if len(untagged) != 1 || untagged[0].Severity != agentconfig.SeverityWarning || !strings.Contains(untagged[0].Message, "v0.0.0-20261001110117-f88bde9ee37a has no release before it") { + t.Fatalf("an untagged build only warns and names its version, got %+v", untagged) } } diff --git a/cmd/config.go b/cmd/config.go index 80c3c29..ebb95d8 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -388,12 +388,12 @@ func resolveEnv(declared, base agentconfig.Config, lookup func(string) (string, var warnings []agentconfig.FieldError work := declared copied := map[string]bool{} // plugins whose Config was copied into work - for _, name := range sortedPluginNames(declared.Plugins) { + for _, name := range slices.Sorted(maps.Keys(declared.Plugins)) { p := declared.Plugins[name] if p == nil { continue } - for _, key := range sortedStringKeys(p.Config) { + 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) { @@ -451,15 +451,6 @@ func basePluginConfigValue(base agentconfig.Config, plugin, key string) string { return "" } -func sortedStringKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - slices.Sort(keys) - return keys -} - // 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. diff --git a/cmd/identity.go b/cmd/identity.go index 0aa0f09..7169120 100644 --- a/cmd/identity.go +++ b/cmd/identity.go @@ -3,6 +3,8 @@ package cmd import ( "context" "fmt" + "maps" + "slices" "strings" "github.com/compliance-framework/agent/internal/inlinepolicy" @@ -98,7 +100,7 @@ func (m loadedModule) where() string { // here is skipped (prefetch reports it). func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig.Config, skip map[string]string, materialized map[string]*inlinepolicy.Materialized, origin policyOrigin) []agentconfig.PolicyError { var out []agentconfig.PolicyError - for _, pluginName := range sortedPluginNames(resolved.Plugins) { + for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { p := resolved.Plugins[pluginName] if p == nil || !p.IsEnabled() { continue @@ -191,7 +193,7 @@ func identityProblems(pluginName string, modules []loadedModule, severity func(a } } } - for _, pkg := range sortedMapKeys(samePackage) { + for _, pkg := range slices.Sorted(maps.Keys(samePackage)) { if identityPackages[pkg] { continue } diff --git a/cmd/inline.go b/cmd/inline.go index 33cb90b..45f2316 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -3,8 +3,9 @@ package cmd import ( "context" "errors" + "maps" "path/filepath" - "sort" + "slices" "strings" "github.com/compliance-framework/agent/internal/inlinepolicy" @@ -18,12 +19,14 @@ import ( const inlineGCKeepPerBundle = 5 // inlineLinksDir is where the stable links of inline bundles live, relative to the agent's -// working directory like the OCI policy cache next to it, so plugins receive an inline -// bundle as .compliance-framework/policies/inline//policies (R82). -var inlineLinksDir = filepath.Join(AgentPolicyDir, "inline") +// working directory like the OCI policy cache next to it, so plugins receive a bundle that +// is not shadowed as .compliance-framework/policies/_inline//policies. The leading +// underscore keeps it apart from the OCI cache: OCI repository path components start with +// [a-z0-9], so no image extracts to _inline. +var inlineLinksDir = filepath.Join(AgentPolicyDir, "_inline") // inlineLayout is where inline bundles are materialized (under the state dir, R31) and -// where plugins receive them (R82). +// where plugins receive them when they are not shadowed. func (rc *reconciler) inlineLayout() inlinepolicy.Layout { links := rc.inlineLinks if links == "" { @@ -68,13 +71,12 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co return res, nil } - // Path shadowing is decided before materializing: a shadowed bundle's tree has no - // continuity policy_id. + // Path shadowing is decided before materializing: it sets the path plugins receive. plan := rc.planShadowing(ctx, resolved, skip, refs) materialized := map[string]*inlinepolicy.Materialized{} var problems []agentconfig.PolicyError - for _, name := range sortedBoolKeys(refs) { + for _, name := range slices.Sorted(maps.Keys(refs)) { m, err := inlinepolicy.Materialize(ctx, rc.inlineLayout(), name, resolved.PolicyBundles[name], rc.boundedResolver(), inlinepolicy.Options{Shadow: plan.shadow[name]}) var perrs inlinepolicy.PolicyErrors @@ -96,7 +98,7 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co // One compile unit per (plugin, policy path) (R21): plugins with different policy_data // are checked separately. - for _, pluginName := range sortedPluginNames(resolved.Plugins) { + for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { p := resolved.Plugins[pluginName] if p == nil || !p.IsEnabled() { continue @@ -120,7 +122,7 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co })...) } } - for _, name := range sortedMaterializedKeys(materialized) { + for _, name := range slices.Sorted(maps.Keys(materialized)) { problems = append(problems, inlinepolicy.OverrideStreams(materialized[name])...) } problems = append(problems, rc.policyIdentities(ctx, resolved, skip, materialized, origin)...) @@ -141,18 +143,17 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co res.dirs = map[string]string{} res.trees = map[string]string{} res.digests = map[string]string{} - for _, name := range sortedMaterializedKeys(materialized) { + for _, name := range slices.Sorted(maps.Keys(materialized)) { m := materialized[name] entry := agentconfig.InlineSourcePrefix + name res.dirs[entry] = m.Path res.trees[entry] = m.Dir res.digests[entry] = m.Digest res.reports = append(res.reports, agentconfig.PolicyBundleReport{ - Source: entry, - Digest: m.Digest, - Extends: m.Extends, - Files: m.Files, - PluginPath: m.Path, + Source: entry, + Digest: m.Digest, + Extends: m.Extends, + Files: m.Files, }) res.artifacts = append(res.artifacts, artifactTree{digest: m.Digest, dir: m.Dir}) if m.Extends != nil { @@ -213,13 +214,11 @@ func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) ( } var reports []agentconfig.PolicyBundleReport var trees []artifactTree - for _, source := range sortedSetKeys(sources) { + for _, source := range slices.Sorted(maps.Keys(sources)) { dir, r, err := rc.sourceInventory(ctx, source) if err != nil { continue } - // The resolver returns the exact path string plugins receive for the source (R77). - r.PluginPath = dir reports = append(reports, r) trees = append(trees, artifactTree{digest: r.Digest, dir: dir}) } @@ -271,9 +270,11 @@ func (rc *reconciler) afterStartup(active *candidate) { // gcInline removes materialized inline bundles that none of keep, the running, pending, // starting or fallback candidate, nor a bundle's stable link uses, and that are not among -// the newest inlineGCKeepPerBundle per bundle. It runs after startup and after every swap, -// so a long-running daemon does not accumulate one directory per revision. It holds -// inlineMu, so it never races activateInline. +// the newest inlineGCKeepPerBundle per bundle, and the plugin views no such candidate uses. +// It runs after startup and right after every swap, so a long-running daemon does not +// accumulate one directory per revision; the configuration still draining after a swap is +// the running candidate, so its trees and views are kept. It holds inlineMu, so it never +// races activateInline. func (rc *reconciler) gcInline(keep ...*candidate) { if !rc.store.Writable() { return @@ -305,8 +306,6 @@ func (rc *reconciler) gcInline(keep ...*candidate) { if err := inlinepolicy.GC(rc.inlineLayout(), dirs, inlineGCKeepPerBundle); err != nil { rc.logger.Warn("Could not clean up old inline policy bundles", "error", err) } - // Views of no kept candidate: they are cheap to rebuild, and a plugin of an earlier - // configuration no longer runs (GC runs after the swap's drain). if root, err := filepath.Abs(rc.viewsRoot()); err == nil { if err := policyview.GC(root, views); err != nil { rc.logger.Warn("Could not clean up old plugin views", "error", err) @@ -315,8 +314,10 @@ func (rc *reconciler) gcInline(keep ...*candidate) { } // activateInline points the stable path of each inline bundle c uses at c's tree (R67), -// then creates the views of the plugins that receive a shadowed bundle. Views are -// content-addressed, so a new tree is a new view and nothing is swapped under a plugin. +// then creates the views of the plugins that receive a shadowed bundle. A view that cannot +// be created is only logged: pluginWorkDir ensures it again before each run of its plugin +// and fails just that run, so one plugin's view never stops the configuration (views are +// content-addressed and survive restarts, so failing here could crash-loop the agent). func (rc *reconciler) activateInline(c *candidate) error { if c == nil || c.runtime == nil || len(c.runtime.inlineTrees) == 0 { return nil @@ -324,48 +325,15 @@ func (rc *reconciler) activateInline(c *candidate) error { rc.inlineMu.Lock() defer rc.inlineMu.Unlock() var errs []error - for _, entry := range sortedStringKeys(c.runtime.inlineTrees) { + for _, entry := range slices.Sorted(maps.Keys(c.runtime.inlineTrees)) { name := strings.TrimPrefix(entry, agentconfig.InlineSourcePrefix) errs = append(errs, inlinepolicy.Activate(rc.inlineLayout(), name, c.runtime.inlineTrees[entry])) } - for _, plugin := range sortedMapKeys(c.runtime.pluginViews) { - errs = append(errs, c.runtime.pluginViews[plugin].Ensure()) + for _, plugin := range slices.Sorted(maps.Keys(c.runtime.pluginViews)) { + view := c.runtime.pluginViews[plugin] + if err := view.Ensure(); err != nil { + rc.logger.Warn("Could not prepare a plugin's view; its runs fail until it is fixed", "plugin", plugin, "view", view.Dir, "error", err) + } } return errors.Join(errs...) } - -func sortedBoolKeys(m map[string]bool) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -func sortedMaterializedKeys(m map[string]*inlinepolicy.Materialized) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -func sortedPluginNames(m map[string]*agentconfig.Plugin) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -func sortedMapKeys[V any](m map[string]V) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/cmd/inline_test.go b/cmd/inline_test.go index c1c2500..c96f33d 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -10,10 +10,7 @@ import ( "strings" "testing" - "github.com/compliance-framework/agent/internal/inlinepolicy" - policy_manager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/hashicorp/go-hclog" ) const inlineBaseConfig = ` @@ -72,7 +69,7 @@ func TestInline_FileBundleMaterializedAndReported(t *testing.T) { h, _ := newInlineHarness(t) active := mustStartup(t, h.rc) dir, ok := active.runtime.inlinePolicyDirs["inline:ssh"] - if !ok || dir != filepath.Join(h.dir, "policies", "inline", "ssh", "policies") { + if !ok || dir != filepath.Join(h.dir, "policies", "_inline", "ssh", "policies") { t.Fatalf("plugins must receive the bundle's stable link: %v", active.runtime.inlinePolicyDirs) } if tree := active.runtime.inlineTrees["inline:ssh"]; !strings.HasPrefix(tree, filepath.Join(h.dir, "state", "inline", "ssh")) { @@ -235,7 +232,7 @@ func TestInline_OverlayDuplicateIdentityRejected_R75(t *testing.T) { func TestInline_PolicyIDIdentities_R75(t *testing.T) { t.Run("continuing id next to the vendor source", func(t *testing.T) { h, vendor := newInlineHarness(t) - // The policy_id the UI writes to continue the vendor stream: /. + // A policy_id that continues the vendor stream: /. override := fmt.Sprintf("package compliance_framework.banner\n\nimport rego.v1\n\npolicy_id := %q\n\ntitle := \"banner\"\n\nviolation[{\"id\": \"b\"}] if not input.banner\n", vendor+"/banner.rego") src, _ := json.Marshal(override) h.remote.publish(1, `{"plugins":{"ssh":{"policies":["ghcr.io/vendor/policies:v1","inline:ssh"]}},"policy_bundles":{"ssh":{"modules":{"banner.rego":`+string(src)+`}}}}`) @@ -261,223 +258,6 @@ func TestInline_PolicyIDIdentities_R75(t *testing.T) { }) } -// TestInline_ReportsPluginPaths_R77: each policy bundle report carries the exact path the -// agent passes plugins for it. -func TestInline_ReportsPluginPaths_R77(t *testing.T) { - h, vendor := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["inline:ssh"] - other: - source: ghcr.io/compliance-framework/plugin-other:v1 - policies: ["ghcr.io/vendor/policies:v1"]`, 1)) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - got := map[string]string{} - for _, b := range r.PolicyBundles { - got[b.Source] = b.PluginPath - } - if got["inline:ssh"] != active.runtime.inlinePolicyDirs["inline:ssh"] || !strings.HasSuffix(got["inline:ssh"], filepath.Join("inline", "ssh", "policies")) { - t.Fatalf("inline plugin-path = %q, want the stable path %q", got["inline:ssh"], active.runtime.inlinePolicyDirs["inline:ssh"]) - } - if got["ghcr.io/vendor/policies:v1"] != vendor { - t.Fatalf("OCI plugin-path = %q, want %q", got["ghcr.io/vendor/policies:v1"], vendor) - } -} - -// r78Vendor is the vendor module of the R78 tests: a titled policy, so plugins record -// evidence for it. -const r78Vendor = "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation contains {\"remarks\": \"b\"} if not input.banner\n" - -// r78Harness is an inline harness whose bundle extends source, resolved by resolve; with -// direct, a second plugin also loads source directly. -func r78Harness(t *testing.T, source string, direct bool, resolve func(context.Context, string) (string, error)) *remoteHarness { - t.Helper() - config := strings.Replace(inlineBaseConfig, "extends: ghcr.io/vendor/policies:v1", "extends: "+source, 1) - if direct { - config = strings.Replace(config, `policies: ["inline:ssh"]`, `policies: ["inline:ssh"] - other: - source: ghcr.io/compliance-framework/plugin-other:v1 - policies: ["`+source+`"]`, 1) - } - h := newRemoteHarness(t, config) - h.rc.resolvePolicy = resolve - return h -} - -// r78Report returns the inline bundle's report and the plugin-path of each source that has -// its own policy-bundles entry. -func r78Report(t *testing.T, h *remoteHarness) (agentconfig.PolicyBundleReport, map[string]string) { - t.Helper() - r := h.remote.lastReport(t) - if r.Status != agentconfig.StatusApplied { - t.Fatalf("expected applied, got %s/%s %v", r.Status, r.Reason, r.PolicyErrors) - } - var inline agentconfig.PolicyBundleReport - direct := map[string]string{} - for _, b := range r.PolicyBundles { - if b.Source == "inline:ssh" { - inline = b - } else { - direct[b.Source] = b.PluginPath - } - } - if inline.Extends == nil { - t.Fatalf("no extends report: %+v", r.PolicyBundles) - } - return inline, direct -} - -// r78Evidence evaluates the policies at policyPath the way a plugin that labels _policy_path -// does and returns the UUID of the banner policy's evidence. -func r78Evidence(t *testing.T, policyPath string) string { - t.Helper() - labels := map[string]string{"type": "ssh", "hostname": "web-1", "_policy_path": policyPath} - evidence, err := policy_manager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil).GenerateResults(context.Background(), policyPath, map[string]any{}) - if err != nil { - t.Fatal(err) - } - for _, e := range evidence { - if e.Labels["_policy"] == "compliance_framework.banner" { - return e.UUID - } - } - t.Fatalf("no banner evidence at %s", policyPath) - return "" -} - -// TestInline_ExtendsPluginPath_R78: an inline bundle's extends report carries the literal path -// plugins would get for the extends source, the same as the source's own plugin-path when a -// plugin loads it directly, and still when none does (the bundle replaced the source, R66). A -// module whose policy_id is extends.plugin-path + "/" + file continues the vendor's stream. -func TestInline_ExtendsPluginPath_R78(t *testing.T) { - const source = "ghcr.io/vendor/policies:v1" - // Where an OCI source is extracted to: relative to the working directory, ending in the - // artifact's policies directory. - const extracted = ".compliance-framework/policies/vendor/policies/v1/policies" - t.Chdir(t.TempDir()) - if err := os.MkdirAll(extracted, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(extracted, "banner.rego"), []byte(r78Vendor), 0o644); err != nil { - t.Fatal(err) - } - resolve := func(_ context.Context, s string) (string, error) { - if s == source { - return extracted, nil - } - return "", errors.New("unknown source " + s) - } - - loaded := r78Harness(t, source, true, resolve) - mustStartup(t, loaded.rc) - inline, direct := r78Report(t, loaded) - if inline.Extends.PluginPath != extracted || direct[source] != extracted { - t.Fatalf("extends plugin-path = %q, source plugin-path = %q, want both %q", inline.Extends.PluginPath, direct[source], extracted) - } - - h := r78Harness(t, source, false, resolve) - h.remote.publish(0, `{}`) - mustStartup(t, h.rc) - inline, direct = r78Report(t, h) - if _, ok := direct[source]; ok { - t.Fatalf("no plugin loads %s directly, yet it is reported: %+v", source, direct) - } - if inline.Extends.PluginPath != extracted { - t.Fatalf("extends plugin-path = %q, want %q", inline.Extends.PluginPath, extracted) - } - - // Override the vendor module with the continuity policy_id built from the report. - policyID := inline.Extends.PluginPath + "/banner.rego" - override := strings.Replace(r78Vendor, "title :=", fmt.Sprintf("policy_id := %q\n\ntitle :=", policyID), 1) - overlay, err := json.Marshal(map[string]any{"policy_bundles": map[string]any{"ssh": map[string]any{"modules": map[string]string{"banner.rego": override}}}}) - if err != nil { - t.Fatal(err) - } - h.remote.publish(1, string(overlay)) - before := h.remote.reportCount() - active := h.poll(t) - if h.remote.reportCount() == before { - t.Fatal("the override revision was not reported") - } - r78Report(t, h) - if forked := policyErrorsWithCode(h.remote.lastReport(t), inlinepolicy.CodePolicyStreamForked); len(forked) != 0 { - t.Fatalf("the continuity policy_id must not fork the stream: %+v", forked) - } - // The run loop points the stable path at the new tree before the next run (R67). - if err := h.rc.activateInline(active); err != nil { - t.Fatal(err) - } - if got, want := r78Evidence(t, active.runtime.inlinePolicyDirs["inline:ssh"]), r78Evidence(t, extracted); got != want { - t.Fatalf("override evidence UUID = %s, want the vendor's %s", got, want) - } -} - -// TestInline_ExtendsPluginPathKeepsALocalSourceLiteral_R78: a local extends source configured -// with a non-clean path is reported as configured, as plugins get it from the real resolver. -func TestInline_ExtendsPluginPathKeepsALocalSourceLiteral_R78(t *testing.T) { - for _, source := range []string{"./policies", "./policies/", "policies/"} { - t.Run(source, func(t *testing.T) { - t.Chdir(t.TempDir()) - if err := os.MkdirAll("policies", 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join("policies", "banner.rego"), []byte(r78Vendor), 0o644); err != nil { - t.Fatal(err) - } - ar := NewAgentRunner() - resolve := func(ctx context.Context, s string) (string, error) { return ar.downloadPolicy(ctx, s, nil) } - - loaded := r78Harness(t, source, true, resolve) - mustStartup(t, loaded.rc) - inline, direct := r78Report(t, loaded) - if inline.Extends.PluginPath != source || direct[source] != source { - t.Fatalf("extends plugin-path = %q, source plugin-path = %q, want both the literal %q", inline.Extends.PluginPath, direct[source], source) - } - - h := r78Harness(t, source, false, resolve) - mustStartup(t, h.rc) - if inline, _ := r78Report(t, h); inline.Extends.PluginPath != source { - t.Fatalf("extends plugin-path = %q, want the literal %q", inline.Extends.PluginPath, source) - } - }) - } -} - -// TestInline_ExtendsPluginPathSurvivesTruncation_R78: shrinking the report keeps both -// plugin-paths whole, as it keeps artifact-digest. The agent never cuts a path: a cut path -// would be a wrong one, so the API drops an oversized one whole (for both fields alike). -func TestInline_ExtendsPluginPathSurvivesTruncation_R78(t *testing.T) { - h, vendor := newInlineHarness(t) - mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if got := r.PolicyBundles[0].Extends.PluginPath; got != vendor { - t.Fatalf("extends plugin-path = %q, want %q", got, vendor) - } - ext := *r.PolicyBundles[0].Extends - r.PolicyBundles = append([]agentconfig.PolicyBundleReport(nil), r.PolicyBundles...) - r.PolicyBundles[0].Extends = &ext - long := strings.Repeat("p/", 2100) + "policies" - r.PolicyBundles[0].Extends.PluginPath = long - plugin := r.PolicyBundles[0].PluginPath - - body, _, err := fitReport(&r, true) - if err != nil { - t.Fatal(err) - } - b := r.PolicyBundles[0] - if !r.Truncated || len(b.Files) != 0 || len(b.Extends.Files) != 0 { - t.Fatalf("the forced fit must drop the file lists: %+v", b) - } - if b.PluginPath != plugin || b.Extends.PluginPath != long { - t.Fatalf("truncation must keep both plugin-paths whole: %q, %q", b.PluginPath, b.Extends.PluginPath) - } - var sent agentconfig.Report - if err := json.Unmarshal(body, &sent); err != nil { - t.Fatal(err) - } - if sent.PolicyBundles[0].Extends.PluginPath != long { - t.Fatal("the sent report must carry extends.plugin-path whole") - } -} - // TestInline_StablePathSwapsOnlyBetweenRuns_R67: a revision prepared while a run is in // progress does not move the stable path under it; the run loop points it at the new tree // before the next run, and back at the previous tree when it falls back. @@ -552,7 +332,7 @@ func TestInline_EvidenceSourceIsTheBundle(t *testing.T) { mustStartup(t, h.rc) cfg := h.rc.running().runtime stable := cfg.inlinePolicyDirs["inline:ssh"] - if !strings.HasSuffix(filepath.ToSlash(stable), "/inline/ssh/policies") { + if !strings.HasSuffix(filepath.ToSlash(stable), "/_inline/ssh/policies") { t.Fatalf("plugins must receive the stable path, got %s", stable) } got := cfg.policySource("inline:ssh", stable) @@ -574,14 +354,14 @@ func TestInline_EvidenceSourceIsTheBundle(t *testing.T) { // plugin's policies does not introduce the file's duplicate, so it still applies. func TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75(t *testing.T) { h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1", "inline:ssh"]`, 1)) - withPluginLib(h, "v0.7.2") // file bundles on an old plugin only warn too + withPluginLib(h, "v0.7.0") // file bundles on an old plugin only warn too h.remote.publish(1, `{"plugins":{"ssh":{"policies":["inline:ssh","ghcr.io/vendor/policies:v1"]}}}`) active := mustStartup(t, h.rc) r := h.remote.lastReport(t) if active.overlay == nil || r.Status != agentconfig.StatusApplied { t.Fatalf("expected the reorder to apply, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) } - for _, code := range []string{agentconfig.PolicyCodeDuplicatePolicyIdentity, agentconfig.PolicyCodePluginLibInlineUnsupported} { + for _, code := range []string{agentconfig.PolicyCodeDuplicatePolicyIdentity, agentconfig.PolicyCodePluginLibViolationSetUnsupported} { got := policyErrorsWithCode(r, code) if len(got) == 0 { t.Fatalf("expected a %s warning, got %+v", code, r.PolicyErrors) @@ -593,7 +373,3 @@ func TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75(t *testing.T) { } } } - -// TestInline_RelativePathContinuesVendorStreams_R82 moved to shadow_test.go: a bundle that -// extends a relative source is now shadowed (TestShadow_PluginReceivesTheVendorPathInItsView); -// the R82 fallback is TestShadow_PluginAlsoLoadingTheSourceFallsBack. diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 89c750c..7139c94 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -64,7 +64,7 @@ type candidate struct { trees []artifactTree warnings []agentconfig.FieldError // R34 file-origin warnings policyWarnings []agentconfig.PolicyError // G3b severity=warning - // plugins are the runtime's plugins with their agent library versions (R76, R79). + // plugins are the runtime's plugins with their agent library versions (R76). plugins []agentconfig.PluginReport } @@ -210,7 +210,7 @@ type reconciler struct { // inlineLinks overrides inlineLinksDir, where plugins receive inline bundles (a test // seam: the default is relative to the working directory). inlineLinks string - // pluginLib reads the agent library version of a prefetched plugin source (R76, R79); + // pluginLib reads the agent library version of a prefetched plugin source (R76); // nil skips the plugin compatibility checks and report. pluginLib pluginLibFunc // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"), and @@ -808,11 +808,10 @@ func (rc *reconciler) takePending() *candidate { return next } -// start points the inline bundles' stable paths at c's trees, then records c as the running +// start activates c's inline bundles (activateInline), then records c as the running // candidate and fallback as the one to fall back to. The run loop calls it only once the -// previous configuration's run returned, so the swap never happens under a running plugin -// (R67). starting and fallback are set together first, so GC keeps the trees of both -// throughout (the old active stays covered until it becomes the fallback). +// previous configuration's run returned (R67). starting and fallback are set first, so GC +// keeps the trees of both throughout. func (rc *reconciler) start(c, fallback *candidate, cancel context.CancelFunc) error { rc.mu.Lock() rc.starting, rc.fallback = c, fallback diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 0bd6656..f1da67f 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -188,7 +188,7 @@ func newRemoteHarness(t *testing.T, content string) *remoteHarness { // 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.inlineLinks = filepath.Join(h.dir, "policies", "inline") + rc.inlineLinks = filepath.Join(h.dir, "policies", "_inline") rc.newRemote = func(agentconfig.Config) remoteAPI { return h.remote } rc.now = h.clock.Now rc.lookupEnv = func(string) (string, bool) { return "", false } diff --git a/cmd/shadow.go b/cmd/shadow.go index 98267b6..3fe8715 100644 --- a/cmd/shadow.go +++ b/cmd/shadow.go @@ -3,27 +3,28 @@ package cmd import ( "context" "fmt" + "maps" "os" "path/filepath" + "slices" "github.com/compliance-framework/agent/internal/inlinepolicy" "github.com/compliance-framework/agent/internal/policyview" "github.com/compliance-framework/api/pkg/agentconfig" ) -// Path shadowing (prototype). A plugin keeps an evidence stream exactly when it receives the -// same policy path string (policy-manager seeds evidence UUIDs from it). So an inline bundle -// that extends a source is given to plugins at the source's own plugin path, and the plugin -// runs in a per-plugin view (internal/policyview) in which that path resolves to the -// bundle's tree. Inherited and overridden modules then keep the vendor streams with any -// plugin build, and no continuity policy_id is needed (R82's injection remains the fallback). +// Path shadowing (R83). Plugins seed evidence UUIDs from the policy path string they +// receive, so an inline bundle that extends a source is given to plugins at the source's own +// path, and each such plugin runs in a view (internal/policyview) where that path resolves to +// the bundle's tree: inherited and overridden modules keep the vendor's streams with any +// plugin build. // // A bundle is shadowed when its extends source's plugin path is shadowable (relative, ending // in policies/: every OCI source) and every enabled plugin that uses it can be given a view: -// the plugin does not also load the source itself or another bundle shadowing the same path, -// and every other relative path it receives can still be resolved in the view. Otherwise the -// bundle falls back to R82 (relative inline path plus continuity policy_id), and loading it -// next to its source is reported by the R75 identity checks as before. +// it does not also load the source or another bundle on the same path, and its other +// relative paths still resolve in the view. Otherwise plugins receive the bundle at its own +// path under inlineLinksDir and its modules start path-based streams (R88; OverrideStreams +// warns). // shadowPlan is what prepareInline decided about shadowing, with the policy paths each // plugin receives for its non-inline entries (resolved once, reused for the views). @@ -54,7 +55,7 @@ func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Co return plan } extendsPath := map[string]string{} - for _, name := range sortedBoolKeys(refs) { + for _, name := range slices.Sorted(maps.Keys(refs)) { b := resolved.PolicyBundles[name] if b == nil || b.Extends == nil { continue @@ -71,7 +72,7 @@ func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Co plan.shadow[name] = true } - for _, pluginName := range sortedPluginNames(resolved.Plugins) { + for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { p := resolved.Plugins[pluginName] if p == nil || !p.IsEnabled() { continue @@ -117,7 +118,7 @@ func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Co } for changed := true; changed; { changed = false - for _, pluginName := range sortedMapKeys(plan.plugins) { + for _, pluginName := range slices.Sorted(maps.Keys(plan.plugins)) { sp := plan.plugins[pluginName] var shadowed, others []string byPath := map[string][]string{} @@ -155,16 +156,16 @@ func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Co } } } - for _, name := range sortedMapKeys(plan.reasons) { + for _, name := range slices.Sorted(maps.Keys(plan.reasons)) { if rc.logOnce("shadow\x00" + name + "\x00" + plan.reasons[name]) { - rc.logger.Info("Inline bundle is not shadowed; plugins receive it at its own path, with continuity policy_ids", "bundle", name, "reason", plan.reasons[name]) + rc.logger.Info("Inline bundle is not shadowed; plugins receive it at its own path, so its modules start new evidence streams", "bundle", name, "reason", plan.reasons[name]) } } return plan } -// fallbackInlinePath is the path plugins receive for an inline bundle that is not shadowed -// (R82), or "" (an absolute tree directory, unaffected by views) without symlinks. +// fallbackInlinePath is the path plugins receive for an inline bundle that is not shadowed, +// or "" (an absolute tree directory, unaffected by views) without symlinks. func (rc *reconciler) fallbackInlinePath(name string) string { l := rc.inlineLayout() if !inlinepolicy.SymlinksSupported(l.Links) { @@ -191,7 +192,7 @@ func (rc *reconciler) buildViews(plan shadowPlan, materialized map[string]*inlin if err != nil { return nil, err } - for _, pluginName := range sortedMapKeys(plan.plugins) { + for _, pluginName := range slices.Sorted(maps.Keys(plan.plugins)) { sp := plan.plugins[pluginName] var shadowed, others []string targets := map[string]string{} // plugin path -> the directory holding the tree diff --git a/cmd/shadow_test.go b/cmd/shadow_test.go index 615ef2c..c1a1ee6 100644 --- a/cmd/shadow_test.go +++ b/cmd/shadow_test.go @@ -5,11 +5,11 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/agent/internal/policyview" policy_manager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/api/pkg/agentconfig" @@ -169,24 +169,19 @@ func TestShadow_PluginReceivesTheVendorPathInItsView(t *testing.T) { if raw, err := os.ReadFile(filepath.Join(shadowExtracted, "keys.rego")); err != nil || string(raw) != shadowVendor["keys.rego"] { t.Fatalf("the agent's own view of the vendor tree must be untouched: %q %v", raw, err) } - // No continuity policy_id: the path string carries the identity. + // The path string carries the identity: inherited modules are the vendor's bytes. raw, err := os.ReadFile(filepath.Join(tree, "banner.rego")) if err != nil || string(raw) != shadowVendor["banner.rego"] { t.Fatalf("an inherited module must be the vendor's bytes, got %q %v", raw, err) } - // Report: plugin-path is what plugins receive, the extends path too; an old library is - // fine, and no stream warnings. - inline, _ := r78Report(t, h) - if inline.PluginPath != shadowExtracted || inline.Extends.PluginPath != shadowExtracted { - t.Fatalf("plugin-path = %q, extends.plugin-path = %q", inline.PluginPath, inline.Extends.PluginPath) - } - for _, code := range []string{agentconfig.PolicyCodePluginLibInlineUnsupported, inlinepolicy.CodePolicyStreamForked, inlinepolicy.CodeContinuityPolicyIDSkipped, agentconfig.PolicyCodeDuplicatePolicyIdentity} { + // Report: an old library is fine, and there are no stream warnings. + for _, code := range []string{inlinepolicy.CodePolicyStreamForked, agentconfig.PolicyCodeDuplicatePolicyIdentity} { if got := policyErrorsWithCode(r, code); len(got) != 0 { t.Fatalf("unexpected %s: %+v", code, got) } } - if len(r.Plugins) != 1 || r.Plugins[0].InlinePolicies != agentconfig.InlinePoliciesSupported || r.Plugins[0].LibVersion != oldLib { + if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != oldLib { t.Fatalf("plugins report = %+v", r.Plugins) } @@ -279,23 +274,27 @@ func TestShadow_NewRevisionIsANewView(t *testing.T) { } // TestShadow_PluginAlsoLoadingTheSourceFallsBack: a plugin that loads the source and the -// bundle together cannot be given a view; the bundle falls back to R82 (relative inline -// path and continuity policy_id), and the duplicate is reported (R75): a warning from the -// file, an error when the overlay introduces it. +// bundle together cannot be given a view; plugins receive the bundle at its own path under +// _inline, its inherited modules start new streams (policy-stream-forked), and the duplicate +// is reported (R75): a warning from the file, an error when the overlay introduces it. func TestShadow_PluginAlsoLoadingTheSourceFallsBack(t *testing.T) { t.Run("file", func(t *testing.T) { h := shadowHarness(t, strings.Replace(shadowConfig, `policies: ["inline:ssh"]`, `policies: ["inline:ssh", "ghcr.io/vendor/policies:v1"]`, 1)) withPluginLib(h, "v0.9.0") active := mustStartup(t, h.rc) - if got := active.runtime.inlinePolicyDirs["inline:ssh"]; filepath.ToSlash(got) != ".compliance-framework/policies/inline/ssh/policies" { - t.Fatalf("plugins receive %q, want the R82 path", got) + if got := active.runtime.inlinePolicyDirs["inline:ssh"]; filepath.ToSlash(got) != ".compliance-framework/policies/_inline/ssh/policies" { + t.Fatalf("plugins receive %q, want the bundle's own path", got) } if active.runtime.pluginViews["ssh"] != nil { t.Fatal("no view without a shadowed bundle") } raw, _ := os.ReadFile(filepath.Join(active.runtime.inlineTrees["inline:ssh"], "banner.rego")) - if !strings.Contains(string(raw), `policy_id := "`+shadowExtracted+`/banner.rego"`) { - t.Fatalf("the R82 fallback appends the continuity policy_id, got %q", raw) + if string(raw) != shadowVendor["banner.rego"] { + t.Fatalf("an inherited module keeps the vendor bytes, got %q", raw) + } + forked := policyErrorsWithCode(h.remote.lastReport(t), inlinepolicy.CodePolicyStreamForked) + if len(forked) == 0 || forked[0].Path != "" || !strings.Contains(forked[0].Message, "inherited modules (banner.rego)") { + t.Fatalf("expected a policy-stream-forked warning for the inherited module, got %+v", h.remote.lastReport(t).PolicyErrors) } dups := policyErrorsWithCode(h.remote.lastReport(t), agentconfig.PolicyCodeDuplicatePolicyIdentity) if len(dups) == 0 || dups[0].Severity != agentconfig.SeverityWarning { @@ -320,21 +319,20 @@ func TestShadow_PluginAlsoLoadingTheSourceFallsBack(t *testing.T) { }) } -// TestShadow_AbsoluteExtendsNeedsPolicyID: a bundle extending an absolute path cannot be -// shadowed, so continuity needs the policy_id; an old plugin is rejected for it (overlay), -// while the same plugin takes a shadowed bundle. -func TestShadow_AbsoluteExtendsNeedsPolicyID(t *testing.T) { +// TestShadow_AbsoluteExtendsIsNotShadowed: a bundle extending an absolute path cannot be +// shadowed; an overlay editing it applies on an old plugin, its modules start new streams +// (warned about) and the plugin gets no view. +func TestShadow_AbsoluteExtendsIsNotShadowed(t *testing.T) { h, _ := newInlineHarness(t) // the vendor is an absolute temp dir - withPluginLib(h, "v0.7.2") + withPluginLib(h, oldLib) h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\ntitle := \"extra v2\"\n\nviolation[{\"id\": \"x\"}] if input.max > data.max\n"}}}}`) active := mustStartup(t, h.rc) r := h.remote.lastReport(t) - if active.overlay != nil || r.Status != agentconfig.StatusRejected { - t.Fatalf("expected rejection, got %s/%s", r.Status, r.Reason) + if active.overlay == nil || r.Status != agentconfig.StatusApplied { + t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) } - gate := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported) - if len(gate) != 1 || !strings.Contains(gate[0].Message, "cannot be shadowed") || !strings.Contains(gate[0].Message, pluginlib.MinInlinePolicy) { - t.Fatalf("expected the policy_id gate error, got %+v", r.PolicyErrors) + if forked := policyErrorsWithCode(r, inlinepolicy.CodePolicyStreamForked); len(forked) != 1 || forked[0].Severity != agentconfig.SeverityWarning { + t.Fatalf("expected one policy-stream-forked warning for the inherited module, got %+v", r.PolicyErrors) } if active.runtime.pluginViews["ssh"] != nil { t.Fatal("an absolute extends path gets no view") @@ -372,63 +370,15 @@ func TestShadow_OldLibOverlay(t *testing.T) { if len(set) != 1 || set[0].Path != "new.rego" { t.Fatalf("expected the set-form error, got %+v", r.PolicyErrors) } - if got := rejectionErrors(r, agentconfig.PolicyCodePluginLibInlineUnsupported); len(got) != 0 { - t.Fatalf("a shadowed bundle needs no policy_id gate, got %+v", got) - } }) } -// TestLibProblems_Shadowing is the relaxed gate (overlay-introduced bundles). -func TestLibProblems_Shadowing(t *testing.T) { - set := []inlinepolicy.Site{{Path: "s.rego", Row: 1, Col: 1}} - shadowed := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "oci", PluginPath: "rel/policies"}, Shadowed: true} - shadowedSet := &inlinepolicy.Materialized{Name: "b", Extends: shadowed.Extends, Shadowed: true, SetViolations: set} - absolute := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/abs", PluginPath: "/abs"}, Continued: map[string]string{"x.rego": "/abs/x.rego"}} - standalone := &inlinepolicy.Materialized{Name: "b"} - type want struct{ gate, set string } // severity of each code, "" = none - for _, tc := range []struct { - name string - version string - m *inlinepolicy.Materialized - want want - }{ - {"shadowed, v0.1.9", oldLib, shadowed, want{}}, - {"shadowed + set form, v0.1.9", oldLib, shadowedSet, want{set: agentconfig.SeverityError}}, - {"shadowed + set form, v0.7.2", "v0.7.2", shadowedSet, want{}}, - {"shadowed + set form, unknown", "", shadowedSet, want{set: agentconfig.SeverityWarning}}, - {"standalone, v0.1.9", oldLib, standalone, want{}}, - {"absolute extends, v0.1.9", oldLib, absolute, want{gate: agentconfig.SeverityError}}, - {"absolute extends, v0.8.1", "v0.8.1", absolute, want{gate: agentconfig.SeverityError}}, - {"absolute extends, unknown", "", absolute, want{gate: agentconfig.SeverityWarning}}, - } { - t.Run(tc.name, func(t *testing.T) { - var got want - for _, e := range libProblems("ssh", tc.version, []*inlinepolicy.Materialized{tc.m}, true) { - switch e.Code { - case agentconfig.PolicyCodePluginLibInlineUnsupported: - got.gate = e.Severity - case agentconfig.PolicyCodePluginLibViolationSetUnsupported: - got.set = e.Severity - } - } - if got != tc.want { - t.Fatalf("got %+v, want %+v", got, tc.want) - } - }) - } - // The file's own bundles only warn. - for _, e := range libProblems("ssh", oldLib, []*inlinepolicy.Materialized{absolute, shadowedSet}, false) { - if e.Severity != agentconfig.SeverityWarning { - t.Fatalf("file-origin problems must warn: %+v", e) - } - } -} - // TestShadow_PluginOwnedViewEntries (rule 1): a plugin that creates a directory relative to // its working directory creates it in its view. When the agent's working directory later // gets the same name, the plugin keeps its own; neither activation nor the plugin's run // fails, and the warning is logged once. A real entry at the shadow link itself is a -// conflict: activation and the run fail with a clear error, and the entry is left alone. +// conflict: the plugin's run fails with a clear error, activation only logs it, and the +// entry is left alone. func TestShadow_PluginOwnedViewEntries(t *testing.T) { h := shadowHarness(t, shadowConfig) withPluginLib(h, oldLib) @@ -480,8 +430,8 @@ func TestShadow_PluginOwnedViewEntries(t *testing.T) { t.Fatal(err) } var conflict *policyview.LinkConflictError - if err := h.rc.activateInline(active); !errors.As(err, &conflict) { - t.Fatalf("activation must fail with a link conflict, got %v", err) + if err := h.rc.activateInline(active); err != nil { + t.Fatalf("a view's conflict must not fail activation, got %v", err) } if _, err := active.runtime.pluginWorkDir("ssh"); !errors.As(err, &conflict) { t.Fatalf("the run must fail with a link conflict, got %v", err) @@ -490,3 +440,162 @@ func TestShadow_PluginOwnedViewEntries(t *testing.T) { t.Fatalf("the conflicting entry must be left alone: %v", err) } } + +// TestShadow_ViewConflictFailsOnlyThatPlugin (M1): a conflict in one plugin's view fails +// only that plugin's runs. Activation logs it and carries on, another plugin's view is +// still prepared, and a restart (the view is content-addressed, so the conflict persists) +// still starts the configuration instead of exiting. +func TestShadow_ViewConflictFailsOnlyThatPlugin(t *testing.T) { + config := strings.Replace(shadowConfig, ` policies: ["inline:ssh"]`, ` policies: ["inline:ssh"] + other: + source: ghcr.io/compliance-framework/plugin-other:v1 + policies: ["inline:ssh"]`, 1) + h := shadowHarness(t, config) + withPluginLib(h, oldLib) + active := mustStartup(t, h.rc) + ssh, other := active.runtime.pluginViews["ssh"], active.runtime.pluginViews["other"] + if ssh == nil || other == nil || ssh.Dir == other.Dir { + t.Fatalf("each plugin needs its own view: %+v", active.runtime.pluginViews) + } + + // The ssh plugin replaced its shadow link with a directory of its own. + link := filepath.Join(ssh.Dir, filepath.Dir(shadowExtracted)) + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(link, "policies"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(other.Dir); err != nil { // and the other view must be rebuilt + t.Fatal(err) + } + + var logs strings.Builder + h.rc.logger = hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Warn}) + if err := h.rc.activateInline(active); err != nil { + t.Fatalf("one plugin's view must not fail the configuration: %v", err) + } + if !strings.Contains(logs.String(), "plugin=ssh") { + t.Fatalf("the conflict must be logged with the plugin, got:\n%s", logs.String()) + } + if _, err := os.Stat(filepath.Join(other.Dir, shadowExtracted, "keys.rego")); err != nil { + t.Fatalf("the other plugin's view must be prepared: %v", err) + } + if dir, err := active.runtime.pluginWorkDir("other"); err != nil || dir != other.Dir { + t.Fatalf("the other plugin must run: %q %v", dir, err) + } + var conflict *policyview.LinkConflictError + if _, err := active.runtime.pluginWorkDir("ssh"); !errors.As(err, &conflict) { + t.Fatalf("the conflicting plugin's run must fail with the link conflict, got %v", err) + } + + // A restart finds the same view: the configuration still starts. + resolve := h.rc.resolvePolicy + h.rc = h.newReconciler() + h.rc.inlineLinks = "" + h.rc.resolvePolicy = resolve + withPluginLib(h, oldLib) + restarted := mustStartup(t, h.rc) + if restarted.runtime.pluginViews["ssh"].Dir != ssh.Dir { + t.Fatal("the view is content-addressed: a restart must reuse it") + } + if _, err := restarted.runtime.pluginWorkDir("ssh"); !errors.As(err, &conflict) { + t.Fatalf("the conflict persists for that plugin only, got %v", err) + } +} + +// TestShadow_PlanDrops: a bundle is not shadowed for a plugin that uses a second bundle on +// the same path, or whose other policy paths cannot be resolved in a view; plugins then +// receive it at its own path, and the reason is logged. +func TestShadow_PlanDrops(t *testing.T) { + const otherSource = "ghcr.io/vendor/other:v1" + for _, tc := range []struct { + name, from, to string + // otherPath is where otherSource is extracted ("" when unused). + otherPath, reason string + }{ + { + name: "two bundles on one path", + from: "policy_bundles:\n", to: "policy_bundles:\n ssh2:\n extends: ghcr.io/vendor/policies:v1\n", + reason: "uses more than one bundle that extends " + shadowExtracted, + }, + { + name: "a policy root in a view directory", + from: `policies: ["inline:ssh"]`, to: `policies: ["inline:ssh", "` + otherSource + `"]`, + otherPath: ".compliance-framework/policies/vendor/policies/other", + reason: "cannot be given a view", + }, + } { + t.Run(tc.name, func(t *testing.T) { + config := strings.Replace(shadowConfig, tc.from, tc.to, 1) + if tc.otherPath == "" { + config = strings.Replace(config, `policies: ["inline:ssh"]`, `policies: ["inline:ssh", "inline:ssh2"]`, 1) + } + h := shadowHarness(t, config) + if tc.otherPath != "" { + if err := os.MkdirAll(tc.otherPath, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tc.otherPath, "other.rego"), []byte("package compliance_framework.other\n\nimport rego.v1\n\ntitle := \"Other\"\n"), 0o644); err != nil { + t.Fatal(err) + } + vendor := h.rc.resolvePolicy + h.rc.resolvePolicy = func(ctx context.Context, source string) (string, error) { + if source == otherSource { + return tc.otherPath, nil + } + return vendor(ctx, source) + } + } + withPluginLib(h, oldLib) + var logs strings.Builder + h.rc.logger = hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Info}) + active := mustStartup(t, h.rc) + if got := active.runtime.inlinePolicyDirs["inline:ssh"]; filepath.ToSlash(got) != ".compliance-framework/policies/_inline/ssh/policies" { + t.Fatalf("plugins receive %q, want the bundle's own path", got) + } + if active.runtime.pluginViews["ssh"] != nil { + t.Fatal("no view without a shadowed bundle") + } + if !strings.Contains(logs.String(), "Inline bundle is not shadowed") || !strings.Contains(logs.String(), tc.reason) { + t.Fatalf("the reason must be logged (%q), got:\n%s", tc.reason, logs.String()) + } + }) + } +} + +// TestPluginCommand: plugins start by their absolute binary path in their working directory +// (a view), without the agent's API credentials. +func TestPluginCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a shell script as the plugin") + } + wd := t.TempDir() + t.Chdir(wd) + if err := os.MkdirAll("bin", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("bin", "plugin"), []byte("#!/bin/sh\npwd -P\necho \"secret=$CCF_API_AUTH_CLIENT_SECRET\"\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CCF_API_AUTH_CLIENT_SECRET", "s3cret") + view := t.TempDir() + + cmd := pluginCommand(filepath.Join("bin", "plugin"), view) + if !filepath.IsAbs(cmd.Path) || cmd.Dir != view { + t.Fatalf("path = %q, dir = %q; want an absolute binary path and the view", cmd.Path, cmd.Dir) + } + out, err := cmd.Output() + if err != nil { + t.Fatalf("a relative binary path must still start in the view: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + want, _ := filepath.EvalSymlinks(view) + if len(lines) != 2 || lines[0] != want || lines[1] != "secret=" { + t.Fatalf("the plugin must run in the view without the agent's credentials, got %q (view %s)", out, want) + } + + if cmd := pluginCommand(filepath.Join("bin", "plugin"), ""); cmd.Dir != "" { + t.Fatalf("without a view the plugin runs in the agent's working directory, got %q", cmd.Dir) + } +} diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 20f39d9..5a1acd8 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -88,50 +88,55 @@ with different identities) stays a warning. For an `extends` bundle, an override replaces by the seeds plugins would compute from the extends source's path and the bundle's path: a changed `package` is `policy-package-changed`, any other difference `policy-stream-forked` (warnings). -### Plugin library gate (R76, R79) - -What a plugin can do 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 gates inline policies on -`pluginlib.MinInlinePolicy` (v0.9.0 final, R81, superseding R80: v0.8.0 and v0.8.1 were released without R74; -versions compare as plain semver, so v0.9.0 release candidates are older than the minimum; update the constant before -tagging if agent#95 ships in a different release). We chose a hard gate over per-feature warnings because a plugin that ignores `policy_id` silently -forks every overridden stream, and one older than v0.7.1 crashes on set-form violations. Only overlay-introduced -inline policies are rejected; file bundles and unknown versions (a `replace` or devel build, which local development -relies on) warn. A pseudo-version counts as its base tag, since an untagged commit after v0.8.x or a v0.9.0 RC may not contain R74. - -### Path shadowing (prototype) - -Instead of making every plugin honour a continuity `policy_id` (R74/R82, which needs plugins rebuilt on agent ≥ -v0.9.0), a bundle that extends a relative source is given to plugins at the source's own path, and the plugin runs -with a per-plugin view directory as its working directory (`internal/policyview`), in which that path's parent links -to the bundle's tree and everything else mirrors the agent's working directory. Evidence identity is then the vendor's -by construction, for every plugin build. The agent resolves every relative policy path of such a plugin through its -view (artifact uploads, source props). R82's `policy_id` remains the fallback where a view cannot represent the paths -(absolute `extends`, a plugin loading the source and the bundle together), and the R79 gate only applies there. +### Path shadowing (R83, R88) + +A plugin seeds evidence UUIDs from the policy path string it receives, so a bundle that extends a relative source is +given to plugins at the source's own path, and the plugin runs with a per-plugin view directory as its working +directory (`internal/policyview`), in which that path's parent links to the bundle's tree and everything else mirrors +the agent's working directory. Evidence identity is then the vendor's by construction, for every plugin build. The +agent resolves every relative policy path of such a plugin through its view (artifact uploads, source props). We +rejected the alternative of appending a continuity `policy_id` to every module that continues a vendor file (R82): it +only worked for plugins rebuilt on agent ≥ v0.9.0 and gave added modules two identities depending on the shadowing +decision. Where a view cannot represent the paths (absolute `extends`, a plugin loading the source and the bundle +together, no symlinks) the bundle is given to plugins at its own `_inline` path and its modules start path-based +streams, with `policy-stream-forked` warnings. Risk: a plugin that relies on its working directory sees the view (mirrored, so reads and writes inside existing directories still reach the agent's; new top-level files stay in the view). Plugin contract (rule 1): plugins must not rely on creating new files relative to their working directory (use absolute paths or `os.TempDir()`); such entries stay in the plugin's view and are removed with it. A real (non-symlink) entry in a view is plugin-owned: when the agent's working directory later gets the same name, the view keeps the plugin's entry instead of mirroring the agent's, warns once per view and name, and never fails a run -or an activation. Only the shadow link is agent-owned: a real entry at its path is a conflict that fails activation -and the run with a clear error and is not removed (falling back to R82 there would need the bundle re-materialized -with continuity `policy_id`s, which is decided before materializing, so the previous configuration keeps running -instead). View GC removes whole views, plugin-owned entries included, and never follows links. +or an activation. Only the shadow link is agent-owned: a real entry at its path is a conflict that fails that +plugin's runs with a clear error and is not removed. Activation only logs it: views are content-addressed and +survive restarts, so failing the configuration over one plugin's view would stop every plugin and could crash-loop +the agent at startup. View GC removes whole views, plugin-owned entries included, and never follows links. + +### Plugin library checks (R76, R88) + +What a plugin can do 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 +(`lib-version`). Shadowing makes inline bundles work with every build, so there is no gate: an overlay-introduced +set-form `violation contains` for a plugin older than v0.7.1 is rejected (that `policy-manager` panics on it), and +an authored `policy_id` for a plugin older than `pluginlib.MinPolicyID` (v0.9.0 final; v0.8.0 and v0.8.1 were +released without R74) is a warning. File bundles and unknown versions (a `replace` or devel build, which local +development relies on) only warn. Versions compare as semver and a pseudo-version counts as its base tag, so +release candidates and untagged commits before the minimum are older. ### Stable inline paths (R67) `policy-manager` seeds evidence UUIDs with the policy file path. Materialized bundles stay write-once, -content-addressed directories (`/inline///policies`), but plugins receive -`.compliance-framework/policies/inline//policies` (R82; `/inline//current/bundle` before), where -`.compliance-framework/policies/inline/` is a symlink swapped with an atomic rename. The symlink is an intermediate path component on purpose: OPA's bundle -loader does not descend into a symlinked root directory and would silently load nothing. The run loop swaps it only +content-addressed directories (`/inline///policies`), but plugins receive a bundle that is not +shadowed at `.compliance-framework/policies/_inline//policies`, where `.compliance-framework/policies/_inline/` +is a symlink swapped with an atomic rename. The path is relative, like an OCI source's, so it does not depend on the +state directory, and `_inline` cannot collide with the OCI cache next to it (repository path components start with +`[a-z0-9]`). The symlink is an intermediate path component on purpose: OPA's bundle loader does not descend into a +symlinked root directory and would silently load nothing. The run loop swaps it only between two configuration runs, after the reload drain, and on a fallback; a plugin run therefore sees one tree from start to end, and its API helper resolves the path when the run starts, so evidence artifacts are the tree the run evaluated. The candidate identity still hashes the digest directories, so any tree change restarts the plugins. GC and the swap share a lock; GC keeps the trees of the running, pending, starting and fallback candidates and whatever -`current` points to. +the bundle's link points to. ### One channel for policy sources (R62) diff --git a/docs/configuration.md b/docs/configuration.md index 60361e9..19dabf8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -289,65 +289,58 @@ policy_bundles: involved, or changes one of the bundles involved) and warnings when they come from the file (R34), even under an overlay that changes something else. Replace the source with the inline bundle instead of listing both. -- **Overrides and evidence streams (R75, R82).** Inherited and overridden vendor modules keep their evidence - stream automatically: while materializing a bundle that `extends` a source, the agent appends - `policy_id := "/"` (or the vendor package's own `policy_id`, when it declares one) to - every non-test `compliance_framework.*` module that continues a vendor file and whose package declares no - `policy_id`: a module inherited unchanged, or an override at the same path that keeps the vendor module's `package`. - An explicit `policy_id` always wins. A package with more than one non-test module in the bundle is skipped with a - `policy-id-continuity-skipped` warning (one `policy_id` rule would name a single file for all of them); declare it - yourself in one module. The materialized tree, its digest, its artifact and the report's `files[]` include the - appended line; `extends.files[]` keeps the vendor's own hashes. A changed `package` is a `policy-package-changed` - warning; an explicit `policy_id` that does not continue the vendor stream is a `policy-stream-forked` warning, - which names the `policy_id` that would continue it. -- **Path shadowing (prototype; takes precedence over the R82 `policy_id` above).** Plugins seed evidence UUIDs from - the policy path *string* they receive, so a plugin that keeps receiving the vendor's path keeps the vendor's - streams, whatever agent library it was built with. When a bundle `extends` a source whose plugin path is relative - and ends in `policies/` (every OCI source, e.g. +- **Path shadowing (R83): inline bundles keep the vendor's evidence streams.** Plugins seed evidence UUIDs from the + policy path *string* they receive, so a plugin that keeps receiving the vendor's path keeps the vendor's streams, + whatever agent library it was built with. When a bundle `extends` a source whose plugin path is relative and ends + in `policies/` (every OCI source, e.g. `.compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies`), plugins receive the bundle **at that exact path**, and each plugin that uses such a bundle runs in its own **view**, `/views///`, as its working directory (the plugin binary is started by absolute path). In the view, the path's parent is a symlink to the bundle's content-addressed directory (whose `policies/` is real: OPA loads nothing from a symlinked root), and every other entry of the agent's working directory is mirrored as a - symlink, so every other relative path resolves as it does for the agent. The tree gets **no** continuity - `policy_id`: inherited and overridden modules continue the vendor streams through the path alone, new modules - start their own path-based streams, deleted ones stop. `plugin-path` (and `extends.plugin-path`) report the - vendor path; `_policy_source` says `inline:` (also for plugins that send no policy evaluations, from - their `_policy_path` label) and evaluation-time artifacts are read through the view, i.e. from the bundle's - tree. Views are content-addressed (a new revision is a new view, nothing is swapped under a running plugin) and - garbage-collected with the inline trees. A bundle is **not** shadowed, and falls back to R82, when its `extends` - path is absolute (or not a `policies/` tree), when a plugin using it also loads the source itself (reported as - `duplicate-policy-identity` as before) or another bundle extending the same source, when another relative policy - path of the plugin cannot be represented in a view (e.g. a single-component path such as `policies`), or without - symlinks. A plugin running in a view sees its working directory as the view: files it creates there (rather than - through a mirrored directory) stay in the view and are removed with it. + symlink, so every other relative path resolves as it does for the agent. Inherited modules, and overrides that + keep the vendor module's `package`, continue the vendor streams through the path alone; new modules start their + own path-based streams, deleted ones stop. `_policy_source` says `inline:` (also for plugins that send no + policy evaluations, from their `_policy_path` label) and evaluation-time artifacts are read through the view, i.e. + from the bundle's tree. Views are content-addressed (a new revision is a new view, nothing is swapped under a + running plugin) and garbage-collected with the inline trees. +- **Bundles that are not shadowed (R88)** are given to plugins at their own path under `_inline` (below), so their + modules start path-based streams. That happens when the `extends` path is absolute (or not a `policies/` tree), + when a plugin using the bundle also loads the source itself (reported as `duplicate-policy-identity` as before) or + another bundle extending the same source, when another relative policy path of the plugin cannot be represented in + a view (e.g. a single-component path such as `policies`), or without symlinks. The agent logs why. +- **Overrides and evidence streams (R75).** The agent compares what plugins will seed each module of an `extends` + bundle with against the vendor module at the same path. A changed `package` is a `policy-package-changed` warning. + An override that does not continue the vendor stream (an own `policy_id`, a dropped vendor `policy_id`, or any + override of a bundle that is not shadowed) is a `policy-stream-forked` warning, which names the vendor's + `policy_id` when it has one; the inherited modules of a bundle that is not shadowed get one such warning for the + bundle. A module keeps the stream of a vendor module that declares a `policy_id` wherever it is loaded from, as + long as it keeps that `policy_id`. +- **Plugin views (rule 1).** A plugin running in a view sees its working directory as the view: files it creates + there (rather than through a mirrored directory) stay in the view and are removed with it. **Plugin contract:** plugins must not rely on creating new files or directories relative to their working - directory; use absolute paths or `os.TempDir()`. Such entries are the plugin's (rule 1): they stay in the plugin's - view and are removed with it. If the agent's working directory later gets an entry with the same name, the plugin - keeps its own (and does not see the agent's); the agent logs one warning per view and name, and never fails the - plugin's run or the configuration's activation over it. The one exception is the shadowed path's link itself (the - parent of the vendor path), which is the agent's: a real entry there means the plugin replaced the link, so - activation and the plugin's run fail with an error naming it (the previous configuration keeps running), and the - agent does not remove it; deleting the view directory rebuilds it. + directory; use absolute paths or `os.TempDir()`. Such entries are the plugin's: if the agent's working directory + later gets an entry with the same name, the plugin keeps its own (and does not see the agent's); the agent logs one + warning per view and name, and never fails the plugin's run or the configuration's activation over it. The one + exception is the shadowed path's link itself (the parent of the vendor path), which is the agent's: a real entry + there means the plugin replaced the link, so **that plugin's runs fail** with an error naming it, until the entry + is removed (deleting the view directory rebuilds it). Activation only logs it, so the configuration and the other + plugins keep running, and a restart does not fail on it either. The agent never removes the entry. - **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any `.rego` file fails with `download-failed`. -- **Where bundles live (R67, R82).** Inline bundles are never downloaded. Each revision of a bundle is a write-once - directory under the state directory named by its tree digest, `/inline///policies/`, but - plugins always receive the same relative path, `.compliance-framework/policies/inline//policies` (relative - to the agent's working directory, like an OCI source's path): `.compliance-framework/policies/inline/` is a - symlink the agent swaps atomically to the running revision's directory, between two configuration runs (never - while a plugin of the previous configuration runs). Evidence UUIDs are seeded with the policy file path, so an - unchanged package keeps its evidence identity across edits of the bundle, and a new module's stream does not - depend on the state directory. Two agents that share a working directory and use the same bundle name would swap - the same link: run one agent per working directory. On a file system without symlinks (Windows without the - privilege), plugins receive the digest directory itself and evidence identity changes with each revision. - Directories no running, pending or fallback configuration uses are garbage-collected, never the one the link - points to; trees and `current` links of the earlier `/inline//current/bundle` layout are removed. -- **Plugin paths (R77).** Each `policy-bundles[]` entry of the configuration report carries `plugin-path`, the exact - path string the agent passes plugins for that source: the stable path for an inline bundle, and the path the - agent extracted an OCI source to, or a local source as configured. It is what a continuity `policy_id` is built - from. An inline bundle's `extends` also carries `plugin-path` (R78), the same path the source would get if a plugin - loaded it directly, so continuity ids use `extends.plugin-path` once the bundle has replaced the source everywhere. +- **Where bundles live (R67).** Inline bundles are never downloaded. Each revision of a bundle is a write-once + directory under the state directory named by its tree digest, `/inline///policies/`. A + bundle that is not shadowed always reaches plugins at the same relative path, + `.compliance-framework/policies/_inline//policies` (relative to the agent's working directory, like an OCI + source's path; the leading `_` keeps it apart from the OCI cache, whose repository paths start with `[a-z0-9]`): + `.compliance-framework/policies/_inline/` is a symlink the agent swaps atomically to the running revision's + directory, between two configuration runs (never while a plugin of the previous configuration runs). Evidence UUIDs + are seeded with the policy file path, so an unchanged module keeps its evidence identity across edits of the + bundle, and does not depend on the state directory. Two agents that share a working directory and use the same + bundle name would swap the same link: run one agent per working directory. On a file system without symlinks + (Windows without the privilege), plugins receive the digest directory itself and evidence identity changes with + each revision. Directories no running, pending or fallback configuration uses are garbage-collected, never the one + the link points to. - **Sources for the UI (R62).** Outside mode `off`, the agent uploads every policy tree it reports (each inline bundle, the tree it extends, and each OCI or local source a plugin uses) as a policy bundle artifact, and reports its `artifact-digest` next to the tree digest, so the UI can show and pre-fill vendor sources. These are the same @@ -359,9 +352,8 @@ policy_bundles: ### Policy identity (`policy_id`, R74) Plugins seed each evidence UUID with the policy's package, its file (the plugin path joined with the module's path in -the bundle) and their `_policy_path` label (the plugin path). So moving a policy (an override in an inline bundle, a -new OCI tag, a renamed bundle, a moved state directory) starts a new evidence stream. A module may declare its -identity instead: +the bundle) and their `_policy_path` label (the plugin path). So moving a policy (a new OCI tag, a renamed bundle, a +bundle that is not shadowed) starts a new evidence stream. A module may declare its identity instead: ```rego package compliance_framework.ssh_deny_password_auth @@ -377,11 +369,10 @@ policy_id := "ssh-deny-password-auth" minus the module's bundle-relative path when it ends in `/`, else the `policy_id` itself. A `policy_id` that names the module's own location seeds as if it had none. Evidence keeps its real `_policy_path` label and gains `_policy_id`. -- **Continue a stream** with `policy_id := "/"`, the literal concatenation of the report's - `plugin-path`, a `/` and the file (not a cleaned join): the old location reproduces the old UUID, so an override - keeps writing to the vendor policy's stream. The UI pre-fills it from the report's `plugin-path`. Because the - `plugin-path` is kept as is, this also works for a local source configured with a non-clean path such as - `./policies` or `policies/` (`"./policies/"`, `"policies//"`). +- **Continue a stream** with `policy_id := "/"`, the literal concatenation of the path plugins + receive for the source, a `/` and the file (not a cleaned join): the old location reproduces the old UUID. This + also works for a local source configured with a non-clean path such as `./policies` or `policies/` + (`"./policies/"`, `"policies//"`). Shadowed bundles need none of this. - **A stable stream**: any other `policy_id` (for example `/`) does not depend on where the bundle lives. - `policy_id` must be `policy_id := ""`, declared once per package, at most 512 characters @@ -389,31 +380,26 @@ policy_id := "ssh-deny-password-auth" | Change | Evidence stream | |---|---| -| override a policy, keeping its `policy_id` (or the continuity `policy_id`) | the same stream | +| override a policy in a shadowed bundle, keeping its `package` and `policy_id` | the same stream | | `delete` the policy | the stream stops receiving evidence | | revert the override | the same stream | | a new policy | a new stream, from its `policy_id` | | publish it into a real bundle with the same `policy_id` | the same stream | | change the overridden module's `package` | a new stream (`policy-package-changed`) | -**Plugins must be rebuilt.** Plugins seed evidence with the `policy-manager` they embed, so `policy_id` only takes -effect for plugins built on an agent library that includes it (agent ≥ v0.9.0, `pluginlib.MinInlinePolicy`). +**`policy_id` needs a rebuilt plugin.** Plugins seed evidence with the `policy-manager` they embed, so an authored +`policy_id` only takes effect for plugins built on an agent library that includes it (agent ≥ v0.9.0, +`pluginlib.MinPolicyID`). Path shadowing needs no rebuild. -### Plugin compatibility (R76, R79, relaxed by path shadowing) +### Plugin compatibility (R76, R88) The agent reads each plugin's agent library version from the binary's Go build info, without starting it, and -reports it as `plugins[]` (`name`, `source`, `lib-version`, `inline-policies`). With path shadowing, -`inline-policies` is `supported` for every known library version (each bundle is checked against it, below) and -`unknown` when the version is unknown; `unsupported` is no longer reported. - -- **Shadowed bundles and bundles without `extends` work with any plugin build**: continuity comes from the path. -- **Bundles that extend a source but are not shadowed** (absolute `extends` path, a plugin that also loads the - source, no symlinks) keep the vendor streams only through the continuity `policy_id`, which needs agent ≥ v0.9.0 - (`pluginlib.MinInlinePolicy`). For an older plugin an overlay that introduces such a bundle is rejected with - `plugin-lib-inline-unsupported`; the running configuration keeps running. +reports it as `plugins[]` (`name`, `source`, `lib-version`). Inline bundles work with every plugin build; two +constructs depend on the library: + - **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect `violation[{...}] if { ... }`: an overlay-introduced authored module that uses them for such a plugin is rejected - with `plugin-lib-violation-set-unsupported`, with that fix. + with `plugin-lib-violation-set-unsupported`, with that fix. The running configuration keeps running. - **An authored `policy_id`** is ignored by plugins older than v0.9.0 (the module's stream follows its path): `plugin-lib-policy-id-unsupported` warning. - **Unknown versions and file-defined bundles only warn** (R34): a `replace`d or `(devel)` build, a pseudo-version diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 6ff9bb4..87a8d69 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -102,9 +102,10 @@ digest until they are downloaded again (a new version, a cleared cache, or a fre volume). The agent looks the policy source up by the exact path it gave the plugin, which is the -path the plugin reports the evaluation under. For an inline bundle that is the bundle's -stable path (`.compliance-framework/policies/inline//policies`, R82), so its evidence carries these props -across revisions. A bundle an inline bundle `extends` is not on the evidence; the +path the plugin reports the evaluation under. For an inline bundle that is the path plugins +receive for it: the extends source's path when the bundle is shadowed (resolved through the +plugin's view), else the bundle's stable path (`.compliance-framework/policies/_inline//policies`), +so its evidence carries these props across revisions. A bundle an inline bundle `extends` is not on the evidence; the configuration report names it (`policy-bundles[].extends`). The agent owns these props: any a plugin sets itself are replaced. They are recorded whether diff --git a/go.mod b/go.mod index 17b7420..8c68658 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.1-0.20261001154205-5baeb7812db2 + github.com/compliance-framework/api v0.20.1-0.20261001212246-52f0c63a6690 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 diff --git a/go.sum b/go.sum index 4df83ba..f1299dc 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.1-0.20261001154205-5baeb7812db2 h1:U2M0NvN6SK4WVoc80rWxgjWatQIRbvqJMWLulHxuefs= -github.com/compliance-framework/api v0.20.1-0.20261001154205-5baeb7812db2/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.20.1-0.20261001212246-52f0c63a6690 h1:zorc4g370DcLH9wtH1JV8280v733UPyx4jr1YTOtrOI= +github.com/compliance-framework/api v0.20.1-0.20261001212246-52f0c63a6690/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= diff --git a/internal/inlinepolicy/activate_test.go b/internal/inlinepolicy/activate_test.go index 157dc16..283d440 100644 --- a/internal/inlinepolicy/activate_test.go +++ b/internal/inlinepolicy/activate_test.go @@ -226,41 +226,3 @@ func TestActivate_SwapIsAtomic(t *testing.T) { t.Fatalf("%d reads did not find a complete tree during the swaps", n) } } - -// TestMaterialize_RebuildsOldLayout: a tree directory from the layout before R67 (files -// directly under ) is rebuilt instead of being reused without its policies/ directory. -func TestMaterialize_RebuildsOldLayout(t *testing.T) { - root := t.TempDir() - b := &agentconfig.PolicyBundle{Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n"}} - m, err := Materialize(context.Background(), testLayout(root), "ssh", b, nil) - if err != nil { - t.Fatal(err) - } - version := filepath.Dir(m.Dir) - if err := os.RemoveAll(version); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(version, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(version, "x.rego"), []byte("old"), 0o644); err != nil { - t.Fatal(err) - } - // A vendor tree with a top-level policies/ directory must not pass for the new layout. - if err := os.MkdirAll(filepath.Join(version, "policies"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(version, "policies", "other.rego"), []byte("old"), 0o644); err != nil { - t.Fatal(err) - } - again, err := Materialize(context.Background(), testLayout(root), "ssh", b, nil) - if err != nil { - t.Fatal(err) - } - if got := readFile(t, again.Dir, "x.rego"); got != b.Modules["x.rego"] { - t.Fatalf("the old layout was not rebuilt: %q", got) - } - if _, err := os.Stat(filepath.Join(again.Dir, "other.rego")); !os.IsNotExist(err) { - t.Fatal("a stale file of the old layout survived the rebuild") - } -} diff --git a/internal/inlinepolicy/check.go b/internal/inlinepolicy/check.go index 06a85c7..d6ff4a9 100644 --- a/internal/inlinepolicy/check.go +++ b/internal/inlinepolicy/check.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "path/filepath" "slices" "strings" @@ -108,7 +109,7 @@ func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { out = append(out, runTests(ctx, in, b.Data, modules)...) // 4. Policy contract. - static, vendorSeen := staticContract(in, modules, pkgs, authoredPkgs) + static, vendorSeen := staticContract(in, modules, authoredPkgs) out = append(out, static...) if !agentconfig.HasPolicyErrors(out) { out = append(out, dryRun(ctx, in, b, pkgs, authoredPkgs, vendorSeen)...) @@ -225,7 +226,7 @@ func deniedReachable(c *ast.Compiler, root string, authored map[string]bool) []d } } - for _, path := range sortedKeys(c.Modules) { + for _, path := range slices.Sorted(maps.Keys(c.Modules)) { if !authored[relPath(root, path)] { continue } @@ -247,7 +248,7 @@ func runTests(ctx context.Context, in CheckInput, bundleData map[string]any, mod var out []agentconfig.PolicyError testCtx, cancel := context.WithTimeout(ctx, TestTimeout) defer cancel() - store := inmem.NewFromObject(mergePolicyData(bundleData, in.PolicyData)) + store := inmem.NewFromObject(policyeval.MergeData(bundleData, in.PolicyData)) sandboxed, stubs, caps := sandboxTestModules(modules) ch, err := tester.NewRunner(). SetCompiler(ast.NewCompiler().WithCapabilities(caps)). @@ -338,22 +339,3 @@ func prefixPlugin(plugin, msg string) string { } return fmt.Sprintf("plugin %s: %s", plugin, msg) } - -// mergePolicyData mirrors policyeval's unexported writePolicyData: nested maps merge -// recursively, anything else replaces. -func mergePolicyData(base, overlay map[string]any) map[string]any { - out := map[string]any{} - for k, v := range base { - out[k] = v - } - for k, v := range overlay { - if vm, ok := v.(map[string]any); ok { - if bm, ok := out[k].(map[string]any); ok { - out[k] = mergePolicyData(bm, vm) - continue - } - } - out[k] = v - } - return out -} diff --git a/internal/inlinepolicy/continuity_test.go b/internal/inlinepolicy/continuity_test.go deleted file mode 100644 index f45aae5..0000000 --- a/internal/inlinepolicy/continuity_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package inlinepolicy - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "os" - "path/filepath" - "testing" - - policyManager "github.com/compliance-framework/agent/policy-manager" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/hashicorp/go-hclog" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// R82: inherited and overridden modules of an inline bundle keep the vendor's evidence -// streams without any policy_id written by the user, and plugins receive the bundle at a -// relative, local-source-style path. - -const ( - // r82Vendor is where the agent extracts the vendor OCI bundle: relative to the working - // directory, ending in the artifact's policies directory. - r82Vendor = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" - // r82Links is where the agent links inline bundles (cmd's inlineLinksDir). - r82Links = ".compliance-framework/policies/inline" -) - -var r82VendorFiles = map[string]string{ - "ssh/require_key_based_ssh.rego": "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH\"\n\nviolation contains {\"id\": \"k\"} if input.password\n", - "ssh/deny_root_login.rego": "package compliance_framework.deny_root_login\n\nimport rego.v1\n\ntitle := \"No root login\"\n\nviolation contains {\"id\": \"r\"} if input.root\n", - "banner.rego": "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"b\"} if not input.banner\n", - "ssh/require_key_based_ssh_test.rego": "package compliance_framework.require_key_based_ssh_test\n\nimport rego.v1\n\ntest_ok if true\n", - "lib/helpers.rego": "package ccf_libs.helpers\n\nimport rego.v1\n\nyes := true\n", -} - -// r82Setup writes the vendor tree at r82Vendor in a new working directory and returns the -// layout the agent uses there (the store under the state directory, given absolute as -// CCF_STATE_DIR would) and a resolver that returns the literal relative vendor path. -func r82Setup(t *testing.T) (Layout, Resolver) { - t.Helper() - wd := t.TempDir() - t.Chdir(wd) - skipWithoutSymlinks(t, wd) - for p, src := range r82VendorFiles { - dst := filepath.Join(r82Vendor, filepath.FromSlash(p)) - require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) - require.NoError(t, os.WriteFile(dst, []byte(src), 0o644)) - } - l := Layout{Store: filepath.Join(wd, ".compliance-framework", "state", "local-dev", "inline"), Links: r82Links} - return l, func(_ context.Context, source string) (string, error) { - require.Equal(t, "ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0", source) - return r82Vendor, nil - } -} - -// r82Materialize materializes and activates bundle "custom" with modules over the vendor. -func r82Materialize(t *testing.T, l Layout, resolve Resolver, modules map[string]string) *Materialized { - t.Helper() - m, err := Materialize(context.Background(), l, "custom", &agentconfig.PolicyBundle{ - Extends: strptr("ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0"), - Modules: modules, - }, resolve) - require.NoError(t, err) - require.NoError(t, Activate(l, "custom", m.Dir)) - return m -} - -type r82Evidence struct { - uuid string - labels map[string]string -} - -// r82Run evaluates policyPath the way the ssh plugin does (labels with _policy_path) and -// returns each package's evidence. -func r82Run(t *testing.T, policyPath string) map[string]r82Evidence { - t.Helper() - labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-4", "_policy_path": policyPath} - evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). - GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) - require.NoError(t, err) - out := map[string]r82Evidence{} - for _, e := range evidence { - out[e.Labels["_policy"]] = r82Evidence{e.UUID, e.Labels} - } - require.NotEmpty(t, out, "no evidence at %s", policyPath) - return out -} - -// TestR82_InheritedModulesKeepTheVendorStream: adding a module to a bundle that extends the -// vendor leaves every inherited module on its vendor evidence UUID. -func TestR82_InheritedModulesKeepTheVendorStream(t *testing.T) { - l, resolve := r82Setup(t) - m := r82Materialize(t, l, resolve, map[string]string{ - "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", - }) - - // (5) The plugin path is the relative, local-source-style path, and the tree loads. - assert.Equal(t, ".compliance-framework/policies/inline/custom/policies", filepath.ToSlash(m.Path)) - got := r82Run(t, m.Path) - assert.Len(t, got, 4, "the three vendor policies and the new one") - - vendor := r82Run(t, r82Vendor) - for _, pkg := range []string{"compliance_framework.require_key_based_ssh", "compliance_framework.deny_root_login", "compliance_framework.banner"} { - require.Contains(t, got, pkg) - assert.Equal(t, vendor[pkg].uuid, got[pkg].uuid, "%s must continue the vendor stream", pkg) - assert.Equal(t, m.Path, got[pkg].labels["_policy_path"], "the evidence keeps the real _policy_path label") - assert.NotEmpty(t, got[pkg].labels["_policy_id"], "the evidence records the continuity policy_id") - } - assert.Equal(t, r82Vendor+"/ssh/require_key_based_ssh.rego", got["compliance_framework.require_key_based_ssh"].labels["_policy_id"]) - - newEvidence := got["compliance_framework.custom_new"] - assert.NotContains(t, newEvidence.labels, "_policy_id", "a new module keeps a path-based stream") - assert.Equal(t, m.Path, newEvidence.labels["_policy_path"]) - - assert.Equal(t, map[string]string{ - "banner.rego": r82Vendor + "/banner.rego", - "ssh/deny_root_login.rego": r82Vendor + "/ssh/deny_root_login.rego", - "ssh/require_key_based_ssh.rego": r82Vendor + "/ssh/require_key_based_ssh.rego", - }, m.Continued, "only inherited compliance_framework modules, not tests, libraries or new modules") - assert.Empty(t, OverrideStreams(m)) - - // The report inventories the tree as written, so its files match the digest and the - // uploaded artifact; the vendor files stay in the extends report. - for _, f := range m.Files { - if f.Path == "banner.rego" { - assert.NotEqual(t, sha(r82VendorFiles["banner.rego"]), f.SHA256) - } - } - for _, f := range m.Extends.Files { - if f.Path == "banner.rego" { - assert.Equal(t, sha(r82VendorFiles["banner.rego"]), f.SHA256) - } - } - - // Another revision of the bundle keeps the path and the streams (R67). - again := r82Materialize(t, l, resolve, map[string]string{ - "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New v2\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", - }) - assert.NotEqual(t, m.Dir, again.Dir) - assert.Equal(t, m.Path, again.Path) - after := r82Run(t, again.Path) - for pkg, e := range got { - assert.Equal(t, e.uuid, after[pkg].uuid, "%s keeps its stream across revisions", pkg) - } -} - -// TestR82_Overrides: an override at the vendor path with the vendor's package continues the -// vendor stream without a policy_id; one with another package starts a new stream and is -// warned about; an explicit policy_id always wins. -func TestR82_Overrides(t *testing.T) { - const path = "ssh/require_key_based_ssh.rego" - const pkg = "compliance_framework.require_key_based_ssh" - t.Run("same package, no policy_id", func(t *testing.T) { - l, resolve := r82Setup(t) - src := "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH (tuned)\"\n\nviolation contains {\"id\": \"k2\"} if input.password\n" - m := r82Materialize(t, l, resolve, map[string]string{path: src}) - assert.Equal(t, withContinuity(src, r82Vendor, path), readFile(t, m.Dir, path)) - assert.Equal(t, r82Run(t, r82Vendor)[pkg].uuid, r82Run(t, m.Path)[pkg].uuid) - assert.Empty(t, OverrideStreams(m), "no policy-stream-forked warning for a module that now continues") - }) - t.Run("changed package", func(t *testing.T) { - l, resolve := r82Setup(t) - src := "package compliance_framework.require_key_based_ssh_v2\n\nimport rego.v1\n\ntitle := \"Key based SSH v2\"\n\nviolation contains {\"id\": \"k2\"} if input.password\n" - m := r82Materialize(t, l, resolve, map[string]string{path: src}) - assert.Equal(t, src, readFile(t, m.Dir, path), "a changed package gets no continuity policy_id") - got := r82Run(t, m.Path) - require.Contains(t, got, pkg+"_v2") - vendorUUID := r82Run(t, r82Vendor)[pkg].uuid - assert.NotEqual(t, vendorUUID, got[pkg+"_v2"].uuid) - warnings := OverrideStreams(m) - require.Len(t, warnings, 1) - assert.Equal(t, agentconfig.PolicyCodePolicyPackageChanged, warnings[0].Code) - assert.Equal(t, path, warnings[0].Path) - }) - t.Run("explicit policy_id", func(t *testing.T) { - l, resolve := r82Setup(t) - src := "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\npolicy_id := \"ssh-key-based\"\n\ntitle := \"Key based SSH\"\n\nviolation contains {\"id\": \"k\"} if input.password\n" - m := r82Materialize(t, l, resolve, map[string]string{path: src}) - assert.Equal(t, src, readFile(t, m.Dir, path), "an explicit policy_id is left alone") - assert.NotContains(t, m.Continued, path) - got := r82Run(t, m.Path)[pkg] - assert.Equal(t, "ssh-key-based", got.labels["_policy_id"]) - assert.NotEqual(t, r82Run(t, r82Vendor)[pkg].uuid, got.uuid) - warnings := OverrideStreams(m) - require.Len(t, warnings, 1, "an explicit policy_id that forks the stream is still warned about") - assert.Equal(t, CodePolicyStreamForked, warnings[0].Code) - }) -} - -// TestR82_MultiModulePackageIsSkipped: a package with two non-test modules in the bundle -// gets no policy_id (it would name one file for both), and a warning says so. -func TestR82_MultiModulePackageIsSkipped(t *testing.T) { - l, resolve := r82Setup(t) - extra := "package compliance_framework.banner\n\nimport rego.v1\n\nbanner_text := \"hello\"\n" - m := r82Materialize(t, l, resolve, map[string]string{"banner_extra.rego": extra}) - assert.Equal(t, r82VendorFiles["banner.rego"], readFile(t, m.Dir, "banner.rego")) - assert.Equal(t, extra, readFile(t, m.Dir, "banner_extra.rego")) - assert.NotContains(t, m.Continued, "banner.rego") - assert.Contains(t, m.Continued, "ssh/deny_root_login.rego", "other packages still continue") - - var skipped []agentconfig.PolicyError - for _, w := range m.Warnings { - if w.Code == CodeContinuityPolicyIDSkipped { - skipped = append(skipped, w) - } - } - require.Len(t, skipped, 1) - assert.Equal(t, "banner.rego", skipped[0].Path) - assert.Equal(t, agentconfig.SeverityWarning, skipped[0].Severity) - assert.Contains(t, skipped[0].Message, "banner.rego, banner_extra.rego") - assert.Contains(t, skipped[0].Message, `policy_id := "`+r82Vendor+`/banner.rego"`) - - vendorUUID := r82Run(t, r82Vendor)["compliance_framework.banner"].uuid - assert.NotContains(t, r82UUIDs(t, m.Path, "compliance_framework.banner"), vendorUUID) - - // A policy_id the user declares in one module of the package continues the stream of - // the vendor file (plugins evaluate each module of a package, so banner.rego's - // evidence is the vendor's and banner_extra.rego's has its own). - fixed := extra + "\npolicy_id := \"" + r82Vendor + "/banner.rego\"\n" - m = r82Materialize(t, l, resolve, map[string]string{"banner_extra.rego": fixed}) - for _, w := range m.Warnings { - assert.NotEqual(t, CodeContinuityPolicyIDSkipped, w.Code) - } - assert.Contains(t, r82UUIDs(t, m.Path, "compliance_framework.banner"), vendorUUID) -} - -// r82UUIDs returns the evidence UUIDs of package pkg at policyPath (one per module). -func r82UUIDs(t *testing.T, policyPath, pkg string) []string { - t.Helper() - labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-4", "_policy_path": policyPath} - evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). - GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) - require.NoError(t, err) - var out []string - for _, e := range evidence { - if e.Labels["_policy"] == pkg { - out = append(out, e.UUID) - } - } - return out -} - -// TestR82_RegoV0Module: a vendor module only Rego v0 parses still gets the policy_id -// (detected with the v0 parser; the appended rule is valid in both). -func TestR82_RegoV0Module(t *testing.T) { - const v0 = "package compliance_framework.legacy\n\ntitle = \"Legacy\"\n\nviolation[{\"id\": \"l\"}] {\n input.password\n}\n" - _, err := ast.ParseModuleWithOpts("legacy.rego", v0, ast.ParserOptions{RegoVersion: ast.RegoV1}) - require.Error(t, err, "the fixture must be v0-only") - - m := &Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "s"}, ExtendsDir: "vendor", Continued: map[string]string{}} - tree := map[string][]byte{"legacy.rego": []byte(v0)} - assert.Empty(t, continueVendorStreams(m, tree, map[string][]byte{"legacy.rego": []byte(v0)})) - assert.Equal(t, withContinuity(v0, "vendor", "legacy.rego"), string(tree["legacy.rego"])) - assert.Equal(t, map[string]string{"legacy.rego": "vendor/legacy.rego"}, m.Continued) -} - -// TestR82_MigratesTheR67Layout: the R67 layout (//current -> with the tree -// in bundle/) is replaced: Materialize writes the new layout and GC removes the old trees -// and the current link. -func TestR82_MigratesTheR67Layout(t *testing.T) { - root := t.TempDir() - skipWithoutSymlinks(t, root) - l := testLayout(root) - old := filepath.Join(l.Store, "ssh", "0123abcd") - require.NoError(t, os.MkdirAll(filepath.Join(old, "bundle"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(old, "bundle", "x.rego"), []byte("package compliance_framework.x\n"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(old, treeMarker), nil, 0o644)) - require.NoError(t, os.Symlink("0123abcd", filepath.Join(l.Store, "ssh", legacyCurrentLink))) - - m, err := Materialize(context.Background(), l, "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n"}}, nil) - require.NoError(t, err) - require.NoError(t, Activate(l, "ssh", m.Dir)) - require.NoError(t, GC(l, map[string]struct{}{m.Dir: {}}, 5)) - - _, err = os.Stat(old) - assert.True(t, os.IsNotExist(err), "the R67 tree must be collected") - _, err = os.Lstat(filepath.Join(l.Store, "ssh", legacyCurrentLink)) - assert.True(t, os.IsNotExist(err), "the R67 current link must be removed") - assert.Equal(t, "package compliance_framework.x\n\ntitle := \"x\"\n", readFile(t, m.Path, "x.rego")) - info, err := os.Lstat(filepath.Join(l.Links, "ssh")) - require.NoError(t, err) - assert.NotZero(t, info.Mode()&os.ModeSymlink, "the bundle's link is a symlink") - info, err = os.Lstat(filepath.Join(l.Links, "ssh", "policies")) - require.NoError(t, err) - assert.True(t, info.IsDir(), "policies/ is a real directory inside the linked tree") -} - -func sha(s string) string { - sum := sha256.Sum256([]byte(s)) - return hex.EncodeToString(sum[:]) -} - -// TestShadow_MaterializeWithoutContinuityPolicyID: with Options.Shadow and a shadowable -// extends path, plugins receive the extends path itself and the tree is written without -// continuity policy_ids; an absolute extends path ignores the option (R82 fallback). -func TestShadow_MaterializeWithoutContinuityPolicyID(t *testing.T) { - l, resolve := r82Setup(t) - b := &agentconfig.PolicyBundle{ - Extends: strptr("ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0"), - Modules: map[string]string{"custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n"}, - } - m, err := Materialize(context.Background(), l, "custom", b, resolve, Options{Shadow: true}) - require.NoError(t, err) - assert.True(t, m.Shadowed) - assert.Equal(t, r82Vendor, m.Path, "plugins receive the vendor's path string") - assert.Empty(t, m.Continued) - assert.Equal(t, r82VendorFiles["banner.rego"], readFile(t, m.Dir, "banner.rego"), "inherited modules keep the vendor bytes") - assert.Empty(t, OverrideStreams(m)) - - abs, err := filepath.Abs(r82Vendor) - require.NoError(t, err) - m, err = Materialize(context.Background(), l, "custom", b, func(context.Context, string) (string, error) { return abs, nil }, Options{Shadow: true}) - require.NoError(t, err) - assert.False(t, m.Shadowed, "an absolute extends path cannot be shadowed") - assert.Equal(t, ".compliance-framework/policies/inline/custom/policies", filepath.ToSlash(m.Path)) - assert.NotEmpty(t, m.Continued, "the R82 continuity policy_id is the fallback") -} diff --git a/internal/inlinepolicy/contract.go b/internal/inlinepolicy/contract.go index d475695..c0df571 100644 --- a/internal/inlinepolicy/contract.go +++ b/internal/inlinepolicy/contract.go @@ -11,6 +11,7 @@ import ( policyManager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/compliance-framework/api/pkg/agentconfig/regocheck" "github.com/compliance-framework/api/pkg/policyeval" "github.com/hashicorp/go-hclog" "github.com/open-policy-agent/opa/v1/ast" @@ -23,8 +24,9 @@ import ( // the authored modules of every bundle before materialization; here the agent adds what only // it can see, on the materialized tree: // -// - static: policyeval.CheckContract on the packages no authored module touches (vendor -// debt: warnings only, R21/R34), and duplicate non-test modules in authored packages; +// - static: policyeval.CheckContract on the tree: every issue of the packages no authored +// module touches (vendor debt: warnings only, R21/R34), and duplicate non-test modules +// of authored packages; // - dynamic: a sandboxed dry run on an empty input through policyeval's Execute and // policy-manager's GetRiskTemplates, exactly the calls plugins make. Problems in a // package that contains an authored module are errors (as the contract rates them); @@ -39,7 +41,7 @@ func packagesOf(modules map[string]*ast.Module, root string) treePackages { out := treePackages{} for path, mod := range modules { if mod != nil && mod.Package != nil { - out[relPath(root, path)] = strings.TrimPrefix(mod.Package.Path.String(), "data.") + out[relPath(root, path)] = packageOf(mod) } } return out @@ -92,55 +94,37 @@ func overrideHint(file string, pkgs treePackages, authored map[string]bool) stri return fmt.Sprintf("%s references rules removed by the override of %s; keep the rule or add `delete: [%s]`", kind, strings.Join(quoted, ", "), file) } -// staticContract runs the static contract check on the vendor-only packages of the tree -// (warnings), and flags authored packages that more than one non-test module defines, which -// regocheck cannot see because it only has the authored modules. It returns the issues and -// the (package, code) pairs it reported for vendor packages, so the dry run does not repeat -// them. -func staticContract(in CheckInput, modules map[string]*ast.Module, pkgs treePackages, authoredPkgs map[string]bool) ([]agentconfig.PolicyError, map[[2]string]bool) { - vendor := map[string]*ast.Module{} +// staticContract runs the static contract check: every issue of the packages no authored +// module touches (vendor debt, warnings), and duplicate-package-module for the packages an +// authored module is in, which regocheck cannot see because it only has the authored modules +// (it checked the rest of the contract on them). It returns the issues and the (package, +// code) pairs it reported for vendor packages, so the dry run does not repeat them. +func staticContract(in CheckInput, modules map[string]*ast.Module, authoredPkgs map[string]bool) ([]agentconfig.PolicyError, map[[2]string]bool) { + vendor, authored := map[string]*ast.Module{}, map[string]*ast.Module{} for path, mod := range modules { - if pkg, ok := pkgs[relPath(in.PolicyDir, path)]; ok && !authoredPkgs[pkg] { + if authoredPkgs[packageOf(mod)] { + authored[path] = mod + } else { vendor[path] = mod } } var out []agentconfig.PolicyError + add := func(issue policyeval.Issue, suffix string) { + e := regocheck.ToPolicyError(in.Bundle, issue) + e.Path = relPath(in.PolicyDir, issue.File) + e.Message = strings.ReplaceAll(e.Message, in.PolicyDir+string(filepath.Separator), "") + suffix + e.Severity = agentconfig.SeverityWarning + out = append(out, e) + } seen := map[[2]string]bool{} for _, issue := range policyeval.CheckContract(vendor) { seen[[2]string{issue.Package, issue.Code}] = true - out = append(out, agentconfig.PolicyError{ - Bundle: in.Bundle, - Path: relPath(in.PolicyDir, issue.File), - Row: issue.Row, - Col: issue.Col, - Message: issue.Message + " (vendor package: a warning only)", - Severity: agentconfig.SeverityWarning, - Code: issue.Code, - }) + add(issue, " (vendor package: a warning only)") } - - for _, pkg := range sortedKeys(authoredPkgs) { - if !policyeval.IsPolicyPackage(pkg) { - continue + for _, issue := range policyeval.CheckContract(authored) { + if issue.Code == agentconfig.PolicyCodeDuplicatePackageModule { + add(issue, "") } - files := pkgs.filesOf(pkg, func(f string) bool { return !policyeval.IsTestFile(f) }) - if len(files) < 2 { - continue - } - at := files[0] - for _, f := range files { - if in.Authored[f] { - at = f - break - } - } - out = append(out, agentconfig.PolicyError{ - Bundle: in.Bundle, - Path: at, - Message: fmt.Sprintf("package %s is defined by %d non-test modules (%s); plugins record evidence for the whole package once per module", pkg, len(files), strings.Join(files, ", ")), - Severity: agentconfig.SeverityWarning, - Code: policyeval.IssueDuplicatePackageModule, - }) } return out, seen } @@ -179,7 +163,7 @@ func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePacka return } seen[key] = true - if code == policyeval.IssueInvalidRiskTemplate || strings.Contains(msg, "risk_templates") { + if code == agentconfig.PolicyCodeInvalidRiskTemplate || strings.Contains(msg, "risk_templates") { riskReported[pkg] = true } out = append(out, agentconfig.PolicyError{ @@ -238,9 +222,9 @@ func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePacka severity = agentconfig.SeverityWarning code = codeEvalConflict case strings.Contains(msg, "decode violation entry") || strings.Contains(msg, "unexpected violations type"): - code = policyeval.IssueInvalidViolation + code = agentconfig.PolicyCodeInvalidViolation case strings.Contains(msg, "decode policy outputs") || strings.Contains(msg, "expected module outputs"): - code = policyeval.IssueInvalidType + code = agentconfig.PolicyCodeInvalidType } if pkg == "" { // Not attributable to a package: never reject on it. @@ -282,7 +266,7 @@ func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePacka } severity = agentconfig.SeverityWarning } - if issue.Code == policyeval.IssueMissingTitle && hasRule(parsed, pkg, "title") { + if issue.Code == agentconfig.PolicyCodeMissingTitle && hasRule(parsed, pkg, "title") { // The title depends on the input; the static check rates that a warning. severity = agentconfig.SeverityWarning } @@ -306,7 +290,7 @@ func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePacka // ValidateResult already reported this package's risk templates. excluded[rte.Package] = true } else { - pkg := failure(err, policyeval.IssueInvalidRiskTemplate) + pkg := failure(err, agentconfig.PolicyCodeInvalidRiskTemplate) if pkg == "" || excluded[pkg] { break } @@ -340,11 +324,11 @@ func locateEvalError(err error, root string, pkgs treePackages) (pkg, file strin // hasRule reports whether any module of pkg defines a rule named name. func hasRule(modules map[string]*ast.Module, pkg, name string) bool { for _, mod := range modules { - if mod == nil || mod.Package == nil || strings.TrimPrefix(mod.Package.Path.String(), "data.") != pkg { + if packageOf(mod) != pkg { continue } for _, rule := range mod.Rules { - if ref := rule.Head.Ref(); len(ref) > 0 && ref[0].Value.Compare(ast.Var(name)) == 0 { + if policyeval.RuleName(rule) == name { return true } } diff --git a/internal/inlinepolicy/contract_test.go b/internal/inlinepolicy/contract_test.go index 49ec405..befc33e 100644 --- a/internal/inlinepolicy/contract_test.go +++ b/internal/inlinepolicy/contract_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/pkg/policyeval" ) // The e2e repro of §13.1: the UI's Override replaced the vendor module with a skeleton, so the @@ -68,7 +67,7 @@ func TestCheck_OverrideBreaksVendorTest_R65(t *testing.T) { Modules: map[string]string{"ssh/ssh_deny_password_auth.rego": sshOverrideSkeleton}, }) errs = errorsOf(check(m, nil), agentconfig.SeverityError) - if len(errs) != 1 || errs[0].Code != policyeval.IssueMissingTitle || errs[0].Path != "ssh/ssh_deny_password_auth.rego" { + if len(errs) != 1 || errs[0].Code != agentconfig.PolicyCodeMissingTitle || errs[0].Path != "ssh/ssh_deny_password_auth.rego" { t.Fatalf("expected one missing-title error on the override, got %+v", errs) } @@ -107,10 +106,10 @@ func TestCheck_Contract_R63(t *testing.T) { "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nviolation contains v if { v := 42 }\n", }}) res := check(m, nil) - if got := codes(res, "x.rego")[policyeval.IssueInvalidViolation]; got != agentconfig.SeverityError { + if got := codes(res, "x.rego")[agentconfig.PolicyCodeInvalidViolation]; got != agentconfig.SeverityError { t.Fatalf("expected an eval error on the authored package, got %+v", res) } - if got := codes(res, "badvendor.rego")[policyeval.IssueInvalidViolation]; got != agentconfig.SeverityWarning { + if got := codes(res, "badvendor.rego")[agentconfig.PolicyCodeInvalidViolation]; got != agentconfig.SeverityWarning { t.Fatalf("expected a warning on the vendor package, got %+v", res) } n := 0 @@ -134,7 +133,7 @@ func TestCheck_Contract_R63(t *testing.T) { } n := 0 for _, e := range res { - if e.Path == "untitled.rego" && e.Code == policyeval.IssueMissingTitle { + if e.Path == "untitled.rego" && e.Code == agentconfig.PolicyCodeMissingTitle { n++ } } @@ -156,7 +155,7 @@ func TestCheck_Contract_R63(t *testing.T) { m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ "x.rego": "package compliance_framework.x\n\nviolation contains {\"id\": \"x\"} if input.x\n", }}) - if got := codes(check(m, nil), "x.rego")[policyeval.IssueMissingTitle]; got != agentconfig.SeverityError { + if got := codes(check(m, nil), "x.rego")[agentconfig.PolicyCodeMissingTitle]; got != agentconfig.SeverityError { t.Fatalf("expected a missing-title error, got %q", got) } }) @@ -165,7 +164,7 @@ func TestCheck_Contract_R63(t *testing.T) { m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ "x.rego": "package compliance_framework.x\n\ntitle := \"x\" if input.enabled\n", }}) - if got := codes(check(m, nil), "x.rego")[policyeval.IssueMissingTitle]; got != agentconfig.SeverityWarning { + if got := codes(check(m, nil), "x.rego")[agentconfig.PolicyCodeMissingTitle]; got != agentconfig.SeverityWarning { t.Fatalf("expected a missing-title warning, got %q", got) } }) @@ -184,7 +183,7 @@ func TestCheck_Contract_R63(t *testing.T) { "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nrisk_templates := [{\"name\": \"r\"}] if true\n", }}) got := codes(check(m, nil), "x.rego") - if got[policyeval.IssueInvalidRiskTemplate] != agentconfig.SeverityError { + if got[agentconfig.PolicyCodeInvalidRiskTemplate] != agentconfig.SeverityError { t.Fatalf("expected an invalid-risk-template error, got %v", got) } }) @@ -194,7 +193,7 @@ func TestCheck_Contract_R63(t *testing.T) { "banner_extra.rego": "package compliance_framework.banner\n\nremarks := \"more\"\n", }}) res := check(m, nil) - if got := codes(res, "banner_extra.rego")[policyeval.IssueDuplicatePackageModule]; got != agentconfig.SeverityWarning { + if got := codes(res, "banner_extra.rego")[agentconfig.PolicyCodeDuplicatePackageModule]; got != agentconfig.SeverityWarning { t.Fatalf("expected a duplicate-package-module warning, got %+v", res) } }) diff --git a/internal/inlinepolicy/identity.go b/internal/inlinepolicy/identity.go index b4c2e5c..188f2fa 100644 --- a/internal/inlinepolicy/identity.go +++ b/internal/inlinepolicy/identity.go @@ -1,8 +1,8 @@ package inlinepolicy import ( - "encoding/json" "fmt" + "maps" "path/filepath" "slices" "strings" @@ -37,9 +37,9 @@ type Site struct { // them. func Identities(files map[string][]byte) []ModuleIdentity { modules := parseModules(files) - ids := policyIDs(modules) + ids := policyeval.StaticPolicyIDs(modules) var out []ModuleIdentity - for _, p := range sortedKeys(modules) { + for _, p := range slices.Sorted(maps.Keys(modules)) { pkg := packageOf(modules[p]) if policyeval.IsTestFile(p) || !policyeval.IsPolicyPackage(pkg) { continue @@ -61,7 +61,7 @@ func TreeIdentities(dir string) ([]ModuleIdentity, error) { // authoredSites returns the authored non-test modules of files that define violation as a // set (`violation contains ...`, R76) and that declare a policy_id rule. func authoredSites(files map[string][]byte, authored map[string]bool) (setViolations, policyIDRules []Site) { - for _, p := range sortedKeys(files) { + for _, p := range slices.Sorted(maps.Keys(files)) { if !authored[p] || !strings.HasSuffix(p, ".rego") || policyeval.IsTestFile(p) { continue } @@ -79,7 +79,7 @@ func authoredSites(files map[string][]byte, authored map[string]bool) (setViolat if loc != nil { site.Row, site.Col = loc.Row, loc.Col } - switch ruleName(rule) { + switch policyeval.RuleName(rule) { case "violation": if setSite == nil && rule.Head.Key != nil && rule.Head.Value == nil { setSite = &site @@ -109,23 +109,13 @@ func SeedOf(id ModuleIdentity, pluginPath string) (string, string) { return policyeval.SeedPath(id.PolicyID, file, pluginPath) } -// ContinuityPolicyID is the policy_id that makes a module at rel in a bundle continue the -// stream of the module at rel in the policy path pluginPath when that one has no policy_id: -// the literal pluginPath + "/" + rel (R77), not a cleaned join. policyeval.SeedPath cleans it -// into the policy_file seed, as OPA cleans the file it gives plugins, and trims rel off the -// raw string for the _policy_path seed, which so keeps a non-clean pluginPath such as -// "./policies" or "policies/" as plugins label it. Compare streams with SeedOf, not by -// comparing ids. -func ContinuityPolicyID(pluginPath, rel string) string { - return pluginPath + "/" + rel -} - -// OverrideStreams warns about authored modules of an extends bundle that replace a vendor -// module at the same path but do not continue its evidence stream (R75): a changed package -// (policy-package-changed), or a policy_id that differs from the vendor's, or, when the -// vendor has none, from ContinuityPolicyID of the extends source (policy-stream-forked). -// Plugins that load the bundle in place of the extends source then start a new stream for -// that policy. +// OverrideStreams warns about the modules of an extends bundle that do not keep the evidence +// stream of the vendor module at the same path (R75), comparing what plugins seed with: the +// vendor module loaded from the extends source and the module loaded from the bundle's path. +// An override that changes the package is policy-package-changed; any other override, and +// the inherited modules of a bundle plugins receive at its own path (not shadowed), are +// policy-stream-forked. A shadowed bundle keeps every stream its modules keep the package +// of. func OverrideStreams(m *Materialized) []agentconfig.PolicyError { if m == nil || m.Extends == nil { return nil @@ -135,154 +125,53 @@ func OverrideStreams(m *Materialized) []agentconfig.PolicyError { vendor[id.Path] = id } var out []agentconfig.PolicyError + warn := func(p, code, format string, args ...any) { + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: p, Severity: agentconfig.SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)}) + } + var inherited []string for _, id := range m.Identities { v, replaced := vendor[id.Path] - if !replaced || !m.Authored[id.Path] { + if !replaced { continue } - warn := func(code, format string, args ...any) { - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: id.Path, Severity: agentconfig.SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)}) - } - if id.Package != v.Package { - warn(agentconfig.PolicyCodePolicyPackageChanged, + if m.Authored[id.Path] && id.Package != v.Package { + warn(id.Path, agentconfig.PolicyCodePolicyPackageChanged, "the override of %s changes its package from %s to %s, which starts a new evidence stream for it; keep `package %s` to continue the vendor policy's stream", id.Path, v.Package, id.Package, v.Package) continue } - // Compare what plugins seed with: the vendor module loaded from the extends source, - // and the override loaded from the bundle's path. vendorFile, vendorPath := SeedOf(v, m.ExtendsDir) file, policyPath := SeedOf(id, m.Path) if file == vendorFile && policyPath == vendorPath { continue } - want := v.PolicyID - if want == "" { - want = ContinuityPolicyID(m.ExtendsDir, id.Path) + if !m.Authored[id.Path] { + inherited = append(inherited, id.Path) + continue } got := "no policy_id" if id.PolicyID != "" { got = fmt.Sprintf("policy_id %q", id.PolicyID) } - warn(CodePolicyStreamForked, - "the override of %s has %s, so plugins that load this bundle instead of %s record its evidence in a new stream; declare `policy_id := %q` to continue the vendor policy's stream", - id.Path, got, m.Extends.Source, want) - } - return out -} - -// CodePolicyStreamForked is the PolicyError code of an override whose policy_id does not -// continue the evidence stream of the vendor module it replaces (R75). Warning. -const CodePolicyStreamForked = "policy-stream-forked" - -// CodeContinuityPolicyIDSkipped is the PolicyError code of a module that continues a vendor -// file but that the agent could not give a continuity policy_id (R82), so plugins that load -// the bundle instead of the extends source record its evidence in a new stream. Warning. -const CodeContinuityPolicyIDSkipped = "policy-id-continuity-skipped" - -// continueVendorStreams appends a continuity policy_id (R82) to every module of files, the -// tree of bundle m that extends vendor, that continues a vendor file and whose package -// declares no policy_id, so plugins loading the bundle in place of the extends source keep -// recording the vendor module's evidence stream: -// -// - a module inherited from the vendor tree unchanged, or -// - an authored module at the path of a vendor module, with the vendor module's package. -// -// The policy_id is the vendor package's own policy_id when it declares one, and otherwise -// ContinuityPolicyID of the extends source's plugin path and the module's path: the stream -// the vendor module had. Only non-test modules of compliance_framework packages count -// (others record no evidence). A package with more than one non-test module in the bundle is -// skipped with a warning: one complete policy_id rule would name a single file for all of -// them, and two would conflict. It records what it appended in m.Continued and returns the -// warnings. -func continueVendorStreams(m *Materialized, files, vendor map[string][]byte) []agentconfig.PolicyError { - var warnings []agentconfig.PolicyError - warn := func(p, format string, args ...any) { - warnings = append(warnings, agentconfig.PolicyError{Bundle: m.Name, Path: p, Severity: agentconfig.SeverityWarning, - Code: CodeContinuityPolicyIDSkipped, Message: fmt.Sprintf(format, args...)}) - } - modules := parseModules(files) - vendorModules := parseModules(vendor) - vendorIDs := policyIDs(vendorModules) - - // Per package: its non-test modules, and whether one of them declares (or imports as) - // policy_id, which always wins. - pkgModules := map[string][]string{} - declared := map[string]bool{} - for _, p := range sortedKeys(modules) { - if policyeval.IsTestFile(p) { - continue - } - pkg := packageOf(modules[p]) - pkgModules[pkg] = append(pkgModules[pkg], p) - if declaresPolicyID(modules[p]) { - declared[pkg] = true + hint := "" + if v.PolicyID != "" { + hint = fmt.Sprintf("; declare `policy_id := %q` to continue the vendor policy's stream", v.PolicyID) } + warn(id.Path, CodePolicyStreamForked, + "the override of %s has %s, so plugins that load this bundle instead of %s record its evidence in a new stream%s", + id.Path, got, m.Extends.Source, hint) } - - for _, p := range sortedKeys(modules) { - mod := modules[p] - pkg := packageOf(mod) - if policyeval.IsTestFile(p) || !policyeval.IsPolicyPackage(pkg) || declared[pkg] { - continue - } - vmod, fromVendor := vendorModules[p] - if !fromVendor { - continue // a new module: its own stream - } - if m.Authored[p] && packageOf(vmod) != pkg { - continue // a changed package starts a new stream (OverrideStreams warns) - } - id := vendorIDs[pkg] - if id == "" { - id = ContinuityPolicyID(m.ExtendsDir, p) - } - if others := pkgModules[pkg]; len(others) > 1 { - warn(p, "package %s has %d modules in the bundle (%s), so the agent cannot declare its policy_id for %s; plugins that load this bundle instead of %s record its evidence in a new stream. Declare `policy_id := %q` in one module of the package to continue the vendor policy's stream", - pkg, len(others), strings.Join(others, ", "), p, m.Extends.Source, id) - continue - } - if !policyeval.ValidPolicyID(id) { - warn(p, "the policy_id that continues the vendor stream of %s would not be valid (%d characters), so plugins that load this bundle instead of %s record its evidence in a new stream", p, len([]rune(id)), m.Extends.Source) - continue - } - literal, err := json.Marshal(id) - if err != nil { - continue - } - src := append(slices.Clip(files[p]), []byte("\npolicy_id := "+string(literal)+"\n")...) - // The module must still parse and now declare exactly this policy_id. - if got := parseRego(p, src); got == nil || policyIDs(map[string]*ast.Module{p: got})[pkg] != id { - warn(p, "the agent could not append the policy_id that continues the vendor stream of %s; plugins that load this bundle instead of %s record its evidence in a new stream", p, m.Extends.Source) - continue - } - files[p] = src - m.Continued[p] = id + if len(inherited) > 0 { + warn("", CodePolicyStreamForked, + "bundle %s is not shadowed, so plugins receive it at %s instead of the path of %s and record the evidence of its inherited modules (%s) in new streams", + m.Name, m.Path, m.Extends.Source, strings.Join(inherited, ", ")) } - return warnings + return out } -// declaresPolicyID reports whether mod defines a rule named policy_id, in any form, or -// imports something as policy_id: either way an appended policy_id rule would not be the -// package's only one. -func declaresPolicyID(mod *ast.Module) bool { - for _, rule := range mod.Rules { - if ruleName(rule) == "policy_id" { - return true - } - } - for _, imp := range mod.Imports { - if imp.Alias == "policy_id" { - return true - } - if ref, ok := imp.Path.Value.(ast.Ref); ok && len(ref) > 1 { - if s, ok := ref[len(ref)-1].Value.(ast.String); ok && string(s) == "policy_id" && imp.Alias == "" { - return true - } - } - } - return false -} +// CodePolicyStreamForked is the PolicyError code of a module of an extends bundle that does +// not continue the evidence stream of the vendor module at its path (R75). Warning. +const CodePolicyStreamForked = "policy-stream-forked" // parseRego parses a module as Rego v1, then as Rego v0, as plugins on either OPA major // would load it; nil when it does not parse or has no package. @@ -309,68 +198,6 @@ func parseModules(files map[string][]byte) map[string]*ast.Module { return out } -// policyIDs returns each package's policy_id: set when the package declares exactly one -// policy_id rule, as an unconditional literal that policyeval.ValidPolicyID accepts. -func policyIDs(modules map[string]*ast.Module) map[string]string { - rules := map[string][]*ast.Rule{} - for p, mod := range modules { - if policyeval.IsTestFile(p) { - continue - } - for _, rule := range mod.Rules { - if ruleName(rule) == "policy_id" { - pkg := packageOf(mod) - rules[pkg] = append(rules[pkg], rule) - } - } - } - out := map[string]string{} - for pkg, rs := range rules { - if len(rs) != 1 { - continue - } - if id, ok := literalPolicyID(rs[0]); ok { - out[pkg] = id - } - } - return out -} - -func literalPolicyID(rule *ast.Rule) (string, bool) { - if len(rule.Head.Ref()) != 1 || len(rule.Head.Args) > 0 || rule.Head.Key != nil || rule.Head.Value == nil || - rule.Default || rule.Else != nil || !unconditional(rule) { - return "", false - } - s, ok := rule.Head.Value.Value.(ast.String) - if !ok || !policyeval.ValidPolicyID(string(s)) { - return "", false - } - return string(s), true -} - -func unconditional(rule *ast.Rule) bool { - if len(rule.Body) != 1 { - return false - } - expr := rule.Body[0] - if expr.Negated || len(expr.With) > 0 { - return false - } - term, ok := expr.Terms.(*ast.Term) - return ok && term.Value.Compare(ast.Boolean(true)) == 0 -} - -func ruleName(rule *ast.Rule) string { - ref := rule.Head.Ref() - if len(ref) == 0 { - return "" - } - if v, ok := ref[0].Value.(ast.Var); ok { - return string(v) - } - return "" -} - func packageOf(mod *ast.Module) string { if mod == nil || mod.Package == nil { return "" diff --git a/internal/inlinepolicy/identity_test.go b/internal/inlinepolicy/identity_test.go index 7b5958b..c7b4b82 100644 --- a/internal/inlinepolicy/identity_test.go +++ b/internal/inlinepolicy/identity_test.go @@ -2,7 +2,6 @@ package inlinepolicy import ( "context" - "fmt" "os" "path/filepath" "testing" @@ -23,8 +22,12 @@ func TestIdentities(t *testing.T) { "cond.rego": []byte("package compliance_framework.cond\n\npolicy_id := \"c\" if input.x\n"), "computed.rego": []byte("package compliance_framework.computed\n\npolicy_id := concat(\"-\", [\"a\", \"b\"])\n"), "lib.rego": []byte("package ccf_libs.helpers\n\npolicy_id := \"lib\"\n"), - "broken.rego": []byte("package compliance_framework.broken\n\ntitle := \n"), - "data.json": []byte("{}"), + // Only `policy_id := ""` rules define it (policyeval.StaticPolicyIDs): a + // function or a set is a contract error, not a second definition. + "fn.rego": []byte("package compliance_framework.fn\n\npolicy_id := \"fn-id\"\n\npolicy_id(x) := x\n"), + "set.rego": []byte("package compliance_framework.set\n\npolicy_id := \"set-id\"\n\npolicy_id contains \"y\"\n"), + "broken.rego": []byte("package compliance_framework.broken\n\ntitle := \n"), + "data.json": []byte("{}"), }) assert.Equal(t, []ModuleIdentity{ {Path: "a.rego", Package: "compliance_framework.a", PolicyID: "a-id"}, @@ -32,6 +35,8 @@ func TestIdentities(t *testing.T) { {Path: "b/b.rego", Package: "compliance_framework.b"}, {Path: "computed.rego", Package: "compliance_framework.computed"}, // only literals count {Path: "cond.rego", Package: "compliance_framework.cond"}, + {Path: "fn.rego", Package: "compliance_framework.fn", PolicyID: "fn-id"}, + {Path: "set.rego", Package: "compliance_framework.set", PolicyID: "set-id"}, {Path: "twice.rego", Package: "compliance_framework.twice"}, // declared twice: none {Path: "twice2.rego", Package: "compliance_framework.twice"}, }, ids) @@ -49,13 +54,13 @@ func TestAuthoredSites(t *testing.T) { assert.Equal(t, []Site{{Path: "set.rego", Row: 5, Col: 1}}, ids) } -// TestOverrideStreams_R75: an override continues the vendor module's stream only with the -// vendor's package and policy_id, or, when the vendor has none, the vendor's legacy policy -// file as its policy_id. +// TestOverrideStreams_R75: an override of a bundle plugins receive at its own path continues +// the vendor module's stream only with the vendor's package and either the vendor's +// policy_id or, when the vendor has none, the vendor's policy file as its policy_id. func TestOverrideStreams_R75(t *testing.T) { const vendorID = "package compliance_framework.ided\n\nimport rego.v1\n\npolicy_id := \"vendor-id\"\n\ntitle := \"x\"\n" dir, resolve := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth, "ided.rego": vendorID}) - continuing := "package compliance_framework.banner\n\nimport rego.v1\n\npolicy_id := \"" + ContinuityPolicyID(dir, "banner.rego") + "\"\n\ntitle := \"Banner\"\n" + continuing := "package compliance_framework.banner\n\nimport rego.v1\n\npolicy_id := \"" + dir + "/banner.rego\"\n\ntitle := \"Banner\"\n" cases := []struct { name string modules map[string]string @@ -63,12 +68,9 @@ func TestOverrideStreams_R75(t *testing.T) { }{ {"continues the legacy stream", map[string]string{"banner.rego": continuing}, map[string]string{}}, {"keeps the vendor policy_id", map[string]string{"ided.rego": vendorID + "\n# changed\n"}, map[string]string{}}, - // R82: the agent appends the continuity policy_id to an override without one. - {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{}}, - {"no policy_id in a package of two modules", map[string]string{"banner.rego": vendorBanner + "\n# changed\n", "banner_more.rego": "package compliance_framework.banner\n\nmore := true\n"}, map[string]string{"banner.rego": CodePolicyStreamForked}}, + {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{"banner.rego": CodePolicyStreamForked}}, {"changed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\npolicy_id := \"other\"\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, - // R82: the agent appends the vendor package's policy_id. - {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{}}, + {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, {"changed package", map[string]string{"max_auth.rego": "package compliance_framework.max_auth_v2\n\ntitle := \"x\"\n"}, map[string]string{"max_auth.rego": agentconfig.PolicyCodePolicyPackageChanged}}, {"new module", map[string]string{"new.rego": "package compliance_framework.new\n\ntitle := \"x\"\n"}, map[string]string{}}, } @@ -80,7 +82,9 @@ func TestOverrideStreams_R75(t *testing.T) { for _, e := range OverrideStreams(m) { require.Equal(t, agentconfig.SeverityWarning, e.Severity) require.Equal(t, "b", e.Bundle) - got[e.Path] = e.Code + if e.Path != "" { // the inherited modules' warning + got[e.Path] = e.Code + } } assert.Equal(t, tc.want, got) }) @@ -90,19 +94,16 @@ func TestOverrideStreams_R75(t *testing.T) { assert.Empty(t, OverrideStreams(m), "a bundle without extends overrides nothing") } -// TestOverrideStreams_NonCleanLocalSource_R77: for a local source configured with a -// non-clean path, the continuity policy_id is the literal "/", and the -// override continues the stream; the cleaned join does not (plugins that label _policy_path -// seed with the literal path). -func TestOverrideStreams_NonCleanLocalSource_R77(t *testing.T) { +// TestOverrideStreams_NonCleanLocalSource: for a local source configured with a non-clean +// path, an authored policy_id that is the literal "/" continues the +// stream; the cleaned join does not (plugins label _policy_path with the literal path). +func TestOverrideStreams_NonCleanLocalSource(t *testing.T) { for _, pluginPath := range []string{"./policies", "./policies/", "policies/"} { t.Run(pluginPath, func(t *testing.T) { t.Chdir(t.TempDir()) require.NoError(t, os.MkdirAll("policies", 0o755)) require.NoError(t, os.WriteFile(filepath.Join("policies", "banner.rego"), []byte(vendorBanner), 0o644)) resolve := func(_ context.Context, source string) (string, error) { return pluginPath, nil } - want := ContinuityPolicyID(pluginPath, "banner.rego") - assert.Equal(t, pluginPath+"/banner.rego", want, "the literal concatenation") override := func(id string) []agentconfig.PolicyError { t.Helper() @@ -111,12 +112,11 @@ func TestOverrideStreams_NonCleanLocalSource_R77(t *testing.T) { require.NoError(t, err) return OverrideStreams(m) } - assert.Empty(t, override(want), "the literal continuity policy_id continues the stream") + assert.Empty(t, override(pluginPath+"/banner.rego"), "the literal path continues the stream") forked := override("policies/banner.rego") require.Len(t, forked, 1, "the cleaned path seeds a different _policy_path") assert.Equal(t, CodePolicyStreamForked, forked[0].Code) - assert.Contains(t, forked[0].Message, fmt.Sprintf("policy_id := %q", want), "the warning names the literal continuity policy_id") }) } } diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go index 2a30c7a..cb76ab2 100644 --- a/internal/inlinepolicy/inlinepolicy_test.go +++ b/internal/inlinepolicy/inlinepolicy_test.go @@ -16,8 +16,6 @@ import ( "time" "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/pkg/policyeval" - "github.com/open-policy-agent/opa/v1/rego" ) // vendorTree writes files under a new directory and returns a resolver serving it. @@ -55,14 +53,8 @@ func readFile(t *testing.T, dir, p string) string { const vendorBanner = "package compliance_framework.banner\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"no-banner\", \"remarks\": \"no banner\"} if not input.banner\n" const vendorMaxAuth = "package compliance_framework.max_auth\n\nviolation contains {\"remarks\": \"too many\"} if input.max_auth > 3\n" -// withContinuity is src with the continuity policy_id the agent appends to a module at rel -// that continues the vendor file at rel under pluginPath (R82). -func withContinuity(src, pluginPath, rel string) string { - return src + "\npolicy_id := \"" + pluginPath + "/" + rel + "\"\n" -} - func TestMaterialize_R17Order(t *testing.T) { - vendor, resolve := vendorTree(t, map[string]string{ + _, resolve := vendorTree(t, map[string]string{ "banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth, "legacy.rego": "package compliance_framework.legacy\n", @@ -94,24 +86,11 @@ func TestMaterialize_R17Order(t *testing.T) { if _, err := os.Stat(filepath.Join(m.Dir, "legacy.rego")); !os.IsNotExist(err) { t.Fatal("deleted vendor module must be gone") } - if got := readFile(t, m.Dir, "max_auth.rego"); got != withContinuity(b.Modules["max_auth.rego"], vendor, "max_auth.rego") { - t.Fatalf("override not applied, or without the continuity policy_id: %q", got) - } - if got := readFile(t, m.Dir, "banner.rego"); got != withContinuity(vendorBanner, vendor, "banner.rego") { - t.Fatalf("inherited module changed beyond the continuity policy_id: %q", got) - } - if got := readFile(t, m.Dir, "lib/helpers.rego"); got != "package ccf_libs.helpers\n" { - t.Fatalf("a library package records no evidence and gets no policy_id: %q", got) + if got := readFile(t, m.Dir, "max_auth.rego"); got != b.Modules["max_auth.rego"] { + t.Fatalf("override not applied: %q", got) } - if got := readFile(t, m.Dir, "banner_test.rego"); got != "package compliance_framework.banner_test\n" { - t.Fatalf("a test module gets no policy_id: %q", got) - } - if got := readFile(t, m.Dir, "extra/new.rego"); got != b.Modules["extra/new.rego"] { - t.Fatalf("a new module starts its own stream and gets no policy_id: %q", got) - } - wantContinued := map[string]string{"banner.rego": vendor + "/banner.rego", "max_auth.rego": vendor + "/max_auth.rego", "nested/deep/a.rego": vendor + "/nested/deep/a.rego"} - if !reflect.DeepEqual(m.Continued, wantContinued) { - t.Fatalf("continued = %v, want %v", m.Continued, wantContinued) + if got := readFile(t, m.Dir, "banner.rego"); got != vendorBanner { + t.Fatalf("an inherited module must keep the vendor bytes: %q", got) } readFile(t, m.Dir, "extra/new.rego") readFile(t, m.Dir, "Policies/Max.Auth.rego") @@ -163,7 +142,7 @@ func contains(list []string, s string) bool { } func TestMaterialize_OverlayNullRestoresVendorModule(t *testing.T) { - vendor, resolve := vendorTree(t, map[string]string{"max_auth.rego": vendorMaxAuth}) + _, resolve := vendorTree(t, map[string]string{"max_auth.rego": vendorMaxAuth}) base := agentconfig.Config{PolicyBundles: map[string]*agentconfig.PolicyBundle{"ssh": { Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{"max_auth.rego": "package compliance_framework.max_auth\n# file override\n"}, @@ -176,7 +155,7 @@ func TestMaterialize_OverlayNullRestoresVendorModule(t *testing.T) { if err != nil { t.Fatal(err) } - if got := readFile(t, m.Dir, "max_auth.rego"); got != withContinuity(vendorMaxAuth, vendor, "max_auth.rego") { + if got := readFile(t, m.Dir, "max_auth.rego"); got != vendorMaxAuth { t.Fatalf("null must restore the vendor module, got %q", got) } } @@ -442,36 +421,6 @@ func TestCheck_Tests(t *testing.T) { }) } -// TestMergePolicyDataParity checks that the test store sees the same data a plugin evaluates. -func TestMergePolicyDataParity(t *testing.T) { - m := materialize(t, map[string]string{}, &agentconfig.PolicyBundle{ - Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n"}, - Data: map[string]any{"a": map[string]any{"x": 1, "y": 2}, "list": []any{1}}, - }) - policyData := map[string]any{"a": map[string]any{"y": 3, "z": map[string]any{"q": true}}, "list": []any{2}, "b": 5} - query, err := policyeval.NewFromBundlePath(m.Dir, policyData, policyeval.Options{}).PrepareForEval(context.Background(), rego.Query("x = data")) - if err != nil { - t.Fatal(err) - } - rs, err := query.Eval(context.Background()) - if err != nil || len(rs) != 1 { - t.Fatalf("eval: %v %v", rs, err) - } - got, _ := json.Marshal(rs[0].Bindings["x"].(map[string]any)) - var gotMap map[string]any - _ = json.Unmarshal(got, &gotMap) - delete(gotMap, "compliance_framework") - - var bundleData map[string]any - _ = json.Unmarshal([]byte(readFile(t, m.Dir, "data.json")), &bundleData) - want, _ := json.Marshal(mergePolicyData(bundleData, policyData)) - var wantMap map[string]any - _ = json.Unmarshal(want, &wantMap) - if !reflect.DeepEqual(gotMap, wantMap) { - t.Fatalf("parity: policyeval sees %v, mergePolicyData gives %v", gotMap, wantMap) - } -} - func TestGC_KeepsActiveAndNewest(t *testing.T) { root := t.TempDir() var dirs []string diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 6d9e5ca..34ba3a9 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -2,9 +2,9 @@ // remote overlay) into write-once directories that plugins load like any other policy path, // and checks them the way plugins will evaluate them (HLD §3.5, R17–R21). // -// It imports api/pkg/agentconfig, api/pkg/policyeval, internal/policytree, policy-manager -// (to dry-run bundles through the exact calls plugins make), OPA v1 and the standard -// library, never cmd. +// It imports api/pkg/agentconfig (and its regocheck), api/pkg/policyeval, +// internal/policytree, internal/policyview, policy-manager (to dry-run bundles through the +// exact calls plugins make), OPA v1 and the standard library, never cmd. package inlinepolicy import ( @@ -53,22 +53,16 @@ func (p PolicyErrors) Error() string { return strings.Join(parts, "; ") } -// Layout is where inline bundles live on disk (R67, R82): +// Layout is where inline bundles live on disk (see docs/configuration.md): // // ///policies/... the tree, write-once and content-addressed (Dir) // / -> // swapped atomically by Activate // -// Plugins receive Path, //policies: the same path for every revision of the -// bundle, so the policy_file of an unchanged package, and with it the evidence UUID that -// policy-manager seeds with it, survives edits of other files. The agent uses the relative -// Links .compliance-framework/policies/inline, next to the OCI policy cache, so an inline -// bundle's path reads like a local source's and does not depend on the state directory -// (R82). The symlink is an intermediate path component on purpose: OPA's bundle loader does -// not descend into a symlinked root directory, but resolves a symlink earlier in the path -// like any other, so policies/ is a real directory inside the tree. -// -// Two agents that share a working directory and use the same bundle name share -// / and would swap it under each other; run one agent per working directory. +// A bundle that is not shadowed reaches plugins as //policies, the same path for +// every revision, so unchanged modules keep their evidence streams across edits. The link is +// an intermediate path component because OPA loads nothing from a symlinked root directory. +// Two agents sharing a working directory and a bundle name would swap / under +// each other; run one agent per working directory. type Layout struct { // Store holds the content-addressed trees (under the agent's state directory). Store string @@ -82,8 +76,8 @@ type Materialized struct { // Dir is the tree itself (///policies). The checks run on it and its // contents never change. Dir string - // Path is what plugins receive: //policies, or Dir when the file system has - // no symlinks (then evidence identity changes with each revision, as before R67). + // Path is what plugins receive: the extends source's path when Shadowed, else + // //policies, or Dir when the file system has no symlinks. Path string Digest string // agentconfig.BundleTreeDigest(files) // Extends describes the vendor tree the bundle extends (nil when standalone). @@ -102,29 +96,22 @@ type Materialized struct { // SetViolations are the authored modules that define violation as a set, which plugins // built on an agent library older than v0.7.1 cannot evaluate; PolicyIDRules are the // authored modules that declare policy_id, which plugins built before R74 ignore (R76). - // Neither counts the policy_id the agent appends (Continued). SetViolations, PolicyIDRules []Site - // Continued maps the modules the agent appended a continuity policy_id to (R82), by - // path, to that policy_id. - Continued map[string]string - // Shadowed is set when plugins receive the bundle at the extends source's own path, - // resolved to Dir inside each plugin's view (path shadowing, see internal/policyview): - // Path is then Extends.PluginPath, and no continuity policy_id is appended, because the - // path string alone keeps the vendor's evidence streams. + // Shadowed is set when plugins receive the bundle at the extends source's own path + // (ExtendsDir), resolved to Dir inside each plugin's view (internal/policyview). Shadowed bool } // Options change how Materialize writes a bundle. type Options struct { // Shadow asks for path shadowing: when the bundle extends a source whose plugin path - // policyview.Shadowable accepts, plugins receive that path (Materialized.Shadowed) and - // the tree is written without continuity policy_ids. Otherwise it is ignored. + // policyview.Shadowable accepts, plugins receive that path (Materialized.Shadowed). + // Otherwise it is ignored. Shadow bool } // Materialize builds bundle name in the R17 order (extends tree, delete, modules, data), -// checks the data-file rule (R18), appends the continuity policy_id to the modules that -// continue a vendor file (R82) and writes the result write-once under l.Store. +// checks the data-file rule (R18) and writes the result write-once under l.Store. func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.PolicyBundle, resolve Resolver, opts ...Options) (*Materialized, error) { var opt Options for _, o := range opts { @@ -136,7 +123,7 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli if !agentconfig.BundleNamePattern.MatchString(name) { return nil, PolicyErrors{{Bundle: name, Message: "invalid bundle name", Severity: agentconfig.SeverityError}} } - m := &Materialized{Name: name, Authored: map[string]bool{}, Continued: map[string]string{}} + m := &Materialized{Name: name, Authored: map[string]bool{}} var errs PolicyErrors warn := func(p, format string, args ...any) { m.Warnings = append(m.Warnings, agentconfig.PolicyError{Bundle: name, Path: p, Message: fmt.Sprintf(format, args...), Severity: agentconfig.SeverityWarning}) @@ -147,7 +134,6 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli // 1. Base tree. files := map[string][]byte{} - var vendorFiles map[string][]byte if b.Extends != nil { if resolve == nil { return nil, fmt.Errorf("%w: no resolver", ErrResolve) @@ -160,7 +146,7 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli if err != nil { return nil, fmt.Errorf("%w %s: %v", ErrResolve, *b.Extends, err) } - if !slices.ContainsFunc(sortedKeys(baseFiles), func(p string) bool { return strings.HasSuffix(p, ".rego") }) { + if !slices.ContainsFunc(slices.Collect(maps.Keys(baseFiles)), func(p string) bool { return strings.HasSuffix(p, ".rego") }) { // An empty or unreadable vendor tree would silently drop every vendor policy. return nil, fmt.Errorf("%w %s: the policy tree at %s has no .rego files", ErrResolve, *b.Extends, dir) } @@ -171,14 +157,9 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli Source: *b.Extends, Digest: agentconfig.BundleTreeDigest(baseFiles), Files: inventory(baseFiles), - // The resolver output is the literal path plugins get for the source (R77), so a - // client can build continuity ids from it even when no plugin loads the source - // directly any more (R78). - PluginPath: dir, } m.ExtendsDir = dir m.ExtendsIdentities = Identities(baseFiles) - vendorFiles = baseFiles files = maps.Clone(baseFiles) } @@ -196,7 +177,7 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli } // 3. Modules. - for _, p := range sortedKeys(b.Modules) { + for _, p := range slices.Sorted(maps.Keys(b.Modules)) { if err := checkRelPath(p); err != nil { fail(p, "%s", err.Error()) continue @@ -226,7 +207,7 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli // 5. Data-name rule (R18): OPA only loads data.json/.yaml/.yml; any other data-like file is // an error when authored and a warning when the vendor shipped it. - for _, p := range sortedKeys(files) { + for _, p := range slices.Sorted(maps.Keys(files)) { ext := strings.ToLower(path.Ext(p)) if ext != ".json" && ext != ".yaml" && ext != ".yml" { continue @@ -245,16 +226,10 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli return nil, errs } - // The authored constructs, before the agent adds anything. m.SetViolations, m.PolicyIDRules = authoredSites(files, m.Authored) - - // 6. Continuity: path shadowing, or else the continuity policy_id (R82). m.Shadowed = opt.Shadow && m.Extends != nil && policyview.Shadowable(m.ExtendsDir) == nil - if m.Extends != nil && !m.Shadowed { - m.Warnings = append(m.Warnings, continueVendorStreams(m, files, vendorFiles)...) - } - // 7. Write once. + // 6. Write once. m.Digest = agentconfig.BundleTreeDigest(files) final := filepath.Join(l.Store, name, strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix)) if err := writeOnce(final, files); err != nil { @@ -269,7 +244,7 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli m.Path = filepath.Join(l.Links, name, treeDir) } - // 8. Inventory: the tree as written, so Files matches Digest and the uploaded artifact. + // 7. Inventory: the tree as written, so Files matches Digest and the uploaded artifact. m.Files = inventory(files) slices.Sort(m.AuthoredTests) m.Identities = Identities(files) @@ -342,7 +317,7 @@ func Inventory(dir string) (string, []agentconfig.PolicyFileReport, error) { func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { out := make([]agentconfig.PolicyFileReport, 0, len(files)) - for _, p := range sortedKeys(files) { + for _, p := range slices.Sorted(maps.Keys(files)) { sum := sha256.Sum256(files[p]) r := agentconfig.PolicyFileReport{Path: p, SHA256: hex.EncodeToString(sum[:])} if strings.HasSuffix(p, ".rego") { @@ -359,15 +334,11 @@ const ( // treeDir is the directory holding the policy tree inside a materialized directory, the // last component of the path plugins receive (like an OCI source's policies/). treeDir = "policies" - // legacyCurrentLink is the per-bundle symlink of the R67 layout (//current - // -> , plugins got //current/bundle). GC removes it (R82). - legacyCurrentLink = "current" // treeMarker marks a complete materialized directory. It sits next to treeDir, outside // the tree. treeMarker = ".ccf-tree" // Name prefixes of transient entries in a bundle directory, and in Links. tmpPrefix = ".tmp-" - currentTmpPrefix = ".current-" symlinkProbeEntry = ".symlink-probe-" ) @@ -378,13 +349,6 @@ func writeOnce(final string, files map[string][]byte) error { if complete(final) { return nil } - if _, err := os.Lstat(final); err == nil { - // A directory in an earlier layout (the tree directly under final before R67, or - // under final/bundle before R82): rebuild it. - if err := os.RemoveAll(final); err != nil { - return err - } - } parent := filepath.Dir(final) if err := os.MkdirAll(parent, 0o755); err != nil { return err @@ -419,8 +383,8 @@ func writeOnce(final string, files map[string][]byte) error { return nil } -// complete reports whether final is a materialized directory in this layout: the marker and -// a real policies/ directory (an R67 directory has the marker and bundle/). +// complete reports whether final is a materialized directory: the marker and a real +// policies/ directory. func complete(final string) bool { info, err := os.Stat(filepath.Join(final, treeMarker)) if err != nil || !info.Mode().IsRegular() { @@ -456,15 +420,11 @@ func symlinksSupported(root string) bool { return ok } -// Activate points bundle name's stable path (Materialized.Path) at dir, a Materialized.Dir -// of that bundle under l.Store. The swap is atomic: a temporary symlink renamed over -// /, so on Linux a reader resolving the stable path sees either the previous -// tree or the new one, never neither (macOS APFS may fail such a racing lookup with EINVAL). -// It is not a snapshot for a reader walking the tree while it is swapped either. Callers -// therefore swap only while no plugin of the previous configuration runs (the agent does it -// between two configuration runs, after the reload drain), and serialize Activate with GC. -// It is a no-op when the tree is already active or the file system has no symlinks (plugins -// then receive dir itself). +// Activate points / at dir, a Materialized.Dir of that bundle under l.Store, +// by renaming a temporary symlink over it. That is atomic for lookups on Linux only (APFS may +// fail a racing lookup with EINVAL) and never a snapshot for a reader walking the tree, so +// callers swap only while no plugin of the previous configuration runs, and serialize +// Activate with GC. It is a no-op when the tree is already active or without symlinks. func Activate(l Layout, name, dir string) error { versionDir := filepath.Dir(filepath.Clean(dir)) if filepath.Base(filepath.Clean(dir)) != treeDir || filepath.Dir(versionDir) != filepath.Join(l.Store, name) { @@ -502,8 +462,7 @@ func Activate(l Layout, name, dir string) error { // GC removes materialized directories under l.Store except those in keep (Materialized.Dir // values, or their parent), the one each bundle's stable link points to, and the perBundle -// newest per bundle, plus abandoned temporary entries, directories of an earlier layout and -// the R67 current links. Callers serialize GC with Activate. +// newest per bundle, plus abandoned temporary entries. Callers serialize GC with Activate. func GC(l Layout, keep map[string]struct{}, perBundle int) error { var errs []error // Abandoned temporary links of Activate. @@ -542,15 +501,10 @@ func GC(l Layout, keep map[string]struct{}, perBundle int) error { var dirs []dirInfo for _, e := range entries { p := filepath.Join(bundleDir, e.Name()) - if strings.HasPrefix(e.Name(), tmpPrefix) || strings.HasPrefix(e.Name(), currentTmpPrefix) { + if strings.HasPrefix(e.Name(), tmpPrefix) { errs = append(errs, os.RemoveAll(p)) continue } - if e.Name() == legacyCurrentLink && e.Type()&os.ModeSymlink != 0 { - // Plugins no longer receive the R67 path, so nothing resolves it. - errs = append(errs, os.Remove(p)) - continue - } if !e.IsDir() { continue } @@ -563,11 +517,6 @@ func GC(l Layout, keep map[string]struct{}, perBundle int) error { if _, ok := keep[filepath.Join(p, treeDir)]; ok { continue } - if !complete(p) { - // A tree of an earlier layout: Materialize rebuilds it when it is needed. - errs = append(errs, os.RemoveAll(p)) - continue - } info, err := e.Info() if err != nil { continue @@ -592,14 +541,5 @@ func absPath(p string) string { return filepath.Clean(p) } -func sortedKeys[V any](m map[string]V) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - slices.Sort(keys) - return keys -} - // SymlinksSupported reports, once per root, whether symlinks can be created under root. func SymlinksSupported(root string) bool { return symlinksSupported(root) } diff --git a/internal/inlinepolicy/streams_test.go b/internal/inlinepolicy/streams_test.go new file mode 100644 index 0000000..4bae0be --- /dev/null +++ b/internal/inlinepolicy/streams_test.go @@ -0,0 +1,280 @@ +package inlinepolicy + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "testing" + + policyManager "github.com/compliance-framework/agent/policy-manager" + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Evidence streams of an inline bundle that extends a vendor bundle: shadowed, plugins +// receive the vendor's path and every module that keeps its package keeps its stream; not +// shadowed, plugins receive the bundle's own path and its modules start path-based streams +// (R88), which OverrideStreams warns about. + +const ( + // streamsVendor is where the agent extracts the vendor OCI bundle: relative to the + // working directory, ending in the artifact's policies directory. + streamsVendor = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" + // streamsLinks is where the agent links inline bundles (cmd's inlineLinksDir). + streamsLinks = ".compliance-framework/policies/_inline" + streamsSource = "ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0" +) + +var streamsVendorFiles = map[string]string{ + "ssh/require_key_based_ssh.rego": "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH\"\n\nviolation contains {\"id\": \"k\"} if input.password\n", + "ssh/deny_root_login.rego": "package compliance_framework.deny_root_login\n\nimport rego.v1\n\npolicy_id := \"ssh-deny-root-login\"\n\ntitle := \"No root login\"\n\nviolation contains {\"id\": \"r\"} if input.root\n", + "banner.rego": "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"b\"} if not input.banner\n", + "ssh/require_key_based_ssh_test.rego": "package compliance_framework.require_key_based_ssh_test\n\nimport rego.v1\n\ntest_ok if true\n", + "lib/helpers.rego": "package ccf_libs.helpers\n\nimport rego.v1\n\nyes := true\n", +} + +// streamsSetup writes the vendor tree at streamsVendor in a new working directory and +// returns the layout the agent uses there (the store under the state directory, absolute as +// CCF_STATE_DIR would give it) and a resolver that returns the literal relative vendor path. +func streamsSetup(t *testing.T) (Layout, Resolver) { + t.Helper() + wd := t.TempDir() + t.Chdir(wd) + skipWithoutSymlinks(t, wd) + for p, src := range streamsVendorFiles { + dst := filepath.Join(streamsVendor, filepath.FromSlash(p)) + require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) + require.NoError(t, os.WriteFile(dst, []byte(src), 0o644)) + } + l := Layout{Store: filepath.Join(wd, ".compliance-framework", "state", "local-dev", "inline"), Links: streamsLinks} + return l, func(_ context.Context, source string) (string, error) { + require.Equal(t, streamsSource, source) + return streamsVendor, nil + } +} + +// streamsMaterialize materializes and activates bundle "custom" with modules over the vendor. +func streamsMaterialize(t *testing.T, l Layout, resolve Resolver, modules map[string]string, opts ...Options) *Materialized { + t.Helper() + m, err := Materialize(context.Background(), l, "custom", &agentconfig.PolicyBundle{ + Extends: strptr(streamsSource), + Modules: modules, + }, resolve, opts...) + require.NoError(t, err) + require.NoError(t, Activate(l, "custom", m.Dir)) + return m +} + +type streamsEvidence struct { + uuid string + labels map[string]string +} + +// streamsRun evaluates policyPath the way the ssh plugin does (labels with _policy_path) +// from working directory dir and returns each package's evidence. +func streamsRun(t *testing.T, dir, policyPath string) map[string]streamsEvidence { + t.Helper() + back, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(dir)) + defer func() { _ = os.Chdir(back) }() + labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-4", "_policy_path": policyPath} + evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). + GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) + require.NoError(t, err) + out := map[string]streamsEvidence{} + for _, e := range evidence { + out[e.Labels["_policy"]] = streamsEvidence{e.UUID, e.Labels} + } + require.NotEmpty(t, out, "no evidence at %s", policyPath) + return out +} + +// shadowView links the shadowed vendor path to m's tree in a new directory, as the agent's +// per-plugin view does (internal/policyview), and returns that directory. +func shadowView(t *testing.T, m *Materialized) string { + t.Helper() + view := t.TempDir() + link := filepath.Join(view, filepath.Dir(streamsVendor)) + require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o755)) + target, err := filepath.Abs(filepath.Dir(m.Dir)) + require.NoError(t, err) + require.NoError(t, os.Symlink(target, link)) + return view +} + +// TestStreams_UnshadowedBundleStartsPathStreams: a bundle plugins receive at its own path +// keeps the vendor files as they are (no policy_id is added), so its inherited modules start +// path-based streams under the relative _inline path, which is warned about once; a module +// whose vendor package declares a policy_id keeps the vendor stream. Another revision keeps +// the path and the streams (R67). +func TestStreams_UnshadowedBundleStartsPathStreams(t *testing.T) { + l, resolve := streamsSetup(t) + m := streamsMaterialize(t, l, resolve, map[string]string{ + "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", + }) + assert.False(t, m.Shadowed) + assert.Equal(t, streamsLinks+"/custom/policies", filepath.ToSlash(m.Path)) + for p, src := range streamsVendorFiles { + assert.Equal(t, src, readFile(t, m.Dir, p), "%s must keep the vendor bytes", p) + } + + wd, err := os.Getwd() + require.NoError(t, err) + got := streamsRun(t, wd, m.Path) + assert.Len(t, got, 4, "the three vendor policies and the new one") + vendor := streamsRun(t, wd, streamsVendor) + for _, pkg := range []string{"compliance_framework.require_key_based_ssh", "compliance_framework.banner"} { + require.Contains(t, got, pkg) + assert.NotEqual(t, vendor[pkg].uuid, got[pkg].uuid, "%s starts a path-based stream", pkg) + assert.Equal(t, m.Path, got[pkg].labels["_policy_path"]) + assert.NotContains(t, got[pkg].labels, "_policy_id") + } + assert.Equal(t, vendor["compliance_framework.deny_root_login"].uuid, got["compliance_framework.deny_root_login"].uuid, + "the vendor's own policy_id keeps the stream") + + warnings := OverrideStreams(m) + require.Len(t, warnings, 1, "one warning for the inherited modules: %+v", warnings) + assert.Equal(t, CodePolicyStreamForked, warnings[0].Code) + assert.Equal(t, agentconfig.SeverityWarning, warnings[0].Severity) + assert.Empty(t, warnings[0].Path) + assert.Contains(t, warnings[0].Message, "inherited modules (banner.rego, ssh/require_key_based_ssh.rego)") + + // The report inventories the tree as written; the vendor files stay in the extends report. + assert.Equal(t, sha(streamsVendorFiles["banner.rego"]), fileSHA(m.Extends.Files, "banner.rego")) + assert.Equal(t, sha(streamsVendorFiles["banner.rego"]), fileSHA(m.Files, "banner.rego")) + + again := streamsMaterialize(t, l, resolve, map[string]string{ + "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New v2\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", + }) + assert.NotEqual(t, m.Dir, again.Dir) + assert.Equal(t, m.Path, again.Path) + after := streamsRun(t, wd, again.Path) + for pkg, e := range got { + assert.Equal(t, e.uuid, after[pkg].uuid, "%s keeps its stream across revisions", pkg) + } +} + +// TestStreams_Shadowed: with Options.Shadow and a shadowable extends path, plugins receive +// the extends path itself, and in a view every inherited module and every override that +// keeps its package keeps the vendor stream, with no warning. An absolute extends path +// ignores the option. +func TestStreams_Shadowed(t *testing.T) { + l, resolve := streamsSetup(t) + const path = "ssh/require_key_based_ssh.rego" + const pkg = "compliance_framework.require_key_based_ssh" + m := streamsMaterialize(t, l, resolve, map[string]string{ + path: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH (tuned)\"\n\nviolation contains {\"id\": \"k2\"} if input.password\n", + "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n", + }, Options{Shadow: true}) + assert.True(t, m.Shadowed) + assert.Equal(t, streamsVendor, m.Path, "plugins receive the vendor's path string") + assert.Empty(t, OverrideStreams(m)) + + wd, err := os.Getwd() + require.NoError(t, err) + vendor := streamsRun(t, wd, streamsVendor) + got := streamsRun(t, shadowView(t, m), streamsVendor) + for _, p := range []string{pkg, "compliance_framework.banner", "compliance_framework.deny_root_login"} { + assert.Equal(t, vendor[p].uuid, got[p].uuid, "%s keeps the vendor stream", p) + } + assert.Contains(t, got, "compliance_framework.custom_new") + + abs, err := filepath.Abs(streamsVendor) + require.NoError(t, err) + m, err = Materialize(context.Background(), l, "custom", &agentconfig.PolicyBundle{Extends: strptr(streamsSource)}, + func(context.Context, string) (string, error) { return abs, nil }, Options{Shadow: true}) + require.NoError(t, err) + assert.False(t, m.Shadowed, "an absolute extends path cannot be shadowed") + assert.Equal(t, streamsLinks+"/custom/policies", filepath.ToSlash(m.Path)) +} + +// TestStreams_Overrides: the R75 warnings about overrides, shadowed or not. +func TestStreams_Overrides(t *testing.T) { + const keyBased = "ssh/require_key_based_ssh.rego" + const rootLogin = "ssh/deny_root_login.rego" + codes := func(warnings []agentconfig.PolicyError) map[string]string { + out := map[string]string{} + for _, w := range warnings { + if w.Path != "" { + out[w.Path] = w.Code + } + } + return out + } + for _, tc := range []struct { + name string + shadow bool + modules map[string]string + want map[string]string // path -> code + hint string // a policy_id the warning suggests + }{ + { + name: "shadowed, same package", + shadow: true, + modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, + want: map[string]string{}, + }, + { + name: "shadowed, changed package", + shadow: true, + modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh_v2\n\nimport rego.v1\n\ntitle := \"v2\"\n"}, + want: map[string]string{keyBased: agentconfig.PolicyCodePolicyPackageChanged}, + }, + { + name: "shadowed, own policy_id", + shadow: true, + modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\npolicy_id := \"ssh-key-based\"\n\ntitle := \"tuned\"\n"}, + want: map[string]string{keyBased: CodePolicyStreamForked}, + }, + { + name: "shadowed, vendor policy_id dropped", + shadow: true, + modules: map[string]string{rootLogin: "package compliance_framework.deny_root_login\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, + want: map[string]string{rootLogin: CodePolicyStreamForked}, + hint: `policy_id := "ssh-deny-root-login"`, + }, + { + name: "not shadowed, same package", + modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, + want: map[string]string{keyBased: CodePolicyStreamForked}, + }, + { + name: "not shadowed, vendor policy_id kept", + modules: map[string]string{rootLogin: "package compliance_framework.deny_root_login\n\nimport rego.v1\n\npolicy_id := \"ssh-deny-root-login\"\n\ntitle := \"tuned\"\n"}, + want: map[string]string{}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + l, resolve := streamsSetup(t) + m := streamsMaterialize(t, l, resolve, tc.modules, Options{Shadow: tc.shadow}) + require.Equal(t, tc.shadow, m.Shadowed) + warnings := OverrideStreams(m) + assert.Equal(t, tc.want, codes(warnings)) + for _, w := range warnings { + assert.Equal(t, agentconfig.SeverityWarning, w.Severity) + if tc.hint != "" && w.Path != "" { + assert.Contains(t, w.Message, tc.hint) + } + } + }) + } +} + +func sha(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func fileSHA(files []agentconfig.PolicyFileReport, p string) string { + for _, f := range files { + if f.Path == p { + return f.SHA256 + } + } + return "" +} diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go index 83890d6..0876a8d 100644 --- a/internal/pluginlib/pluginlib.go +++ b/internal/pluginlib/pluginlib.go @@ -1,5 +1,5 @@ // Package pluginlib reads which version of this module (the agent library) a plugin binary -// was built with, and decides what the plugin supports (R76, R79). +// was built with, and decides what the plugin supports (R76). // // Plugins evaluate policies with the policy-manager they embed, so what a plugin can do with // a policy depends on the agent library it was compiled against, not on the running agent. @@ -26,14 +26,14 @@ const ( // set (`violation contains {...}`, agent#86). Older plugins expect an object // (`violation[{...}] if { ... }`) and crash on a set. MinViolationSet = "v0.7.1" - // MinInlinePolicy is the first agent library release with policy_id seeding (R74), and - // so the first whose plugins may use inline policy bundles (R79). It also covers - // MinViolationSet. R74 ships in v0.9.0 (R81, superseding R80): v0.8.0 and v0.8.1 were - // released without it. The minimum is the final release, so v0.9.0 release candidates - // and pseudo-versions based on them are older (AtLeast). + // MinPolicyID is the first agent library release whose policy-manager seeds evidence + // with an authored policy_id (R74): agent#95 ships in v0.9.0 (v0.8.0 and v0.8.1 were + // released without it). Older plugins ignore policy_id. The minimum is the final + // release, so v0.9.0 release candidates and pseudo-versions based on them are older + // (AtLeast). It also covers MinViolationSet. // // Update before tagging if agent#95 ships in a different release. - MinInlinePolicy = "v0.9.0" + MinPolicyID = "v0.9.0" ) // Version returns the version of AgentModule the plugin binary at path was built with, or "" diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go index 7623751..2d4b320 100644 --- a/internal/pluginlib/pluginlib_test.go +++ b/internal/pluginlib/pluginlib_test.go @@ -22,32 +22,32 @@ func TestAtLeast(t *testing.T) { {"v0.7.2-0.20260601000000-abcdefabcdef", MinViolationSet, true, true}, // after v0.7.1 {"v0.7.1-0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.0, before v0.7.1 {"v0.7.1-rc1", MinViolationSet, false, true}, // semver: before v0.7.1 - {"v0.8.0-rc1", MinInlinePolicy, false, true}, // predate R74 - {"v0.8.0-rc4", MinInlinePolicy, false, true}, - {"v0.8.0-rc4.0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.8.0-rc4 - {"v0.7.2", MinInlinePolicy, false, true}, - {"v0.8.0", MinInlinePolicy, false, true}, // released without R74 (R81) - {"v0.8.1", MinInlinePolicy, false, true}, // released without R74 (R81) - {"v0.8.2-0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.8.1 - {"v0.9.0-rc1", MinInlinePolicy, false, true}, // semver: before v0.9.0 - {"v0.9.0-rc1.0.20261001000000-abcdefabcdef", MinInlinePolicy, false, true}, // after v0.9.0-rc1 - {"v0.9.0", MinInlinePolicy, true, true}, - {"v0.9.1-0.20261001000000-abcdefabcdef", MinInlinePolicy, true, true}, // after v0.9.0 - {"v0.9.1", MinInlinePolicy, true, true}, - {"v0.10.0", MinInlinePolicy, true, true}, - {"v1.0.0", MinInlinePolicy, true, true}, - {"", MinInlinePolicy, false, false}, - {"(devel)", MinInlinePolicy, false, false}, - {"v0.0.0-20261001000000-abcdefabcdef", MinInlinePolicy, false, false}, // no tag before it - {"garbage", MinInlinePolicy, false, false}, + {"v0.8.0-rc1", MinPolicyID, false, true}, // predate R74 + {"v0.8.0-rc4", MinPolicyID, false, true}, + {"v0.8.0-rc4.0.20261001000000-abcdefabcdef", MinPolicyID, false, true}, // after v0.8.0-rc4 + {"v0.7.2", MinPolicyID, false, true}, + {"v0.8.0", MinPolicyID, false, true}, // released without R74 (R81) + {"v0.8.1", MinPolicyID, false, true}, // released without R74 (R81) + {"v0.8.2-0.20261001000000-abcdefabcdef", MinPolicyID, false, true}, // after v0.8.1 + {"v0.9.0-rc1", MinPolicyID, false, true}, // semver: before v0.9.0 + {"v0.9.0-rc1.0.20261001000000-abcdefabcdef", MinPolicyID, false, true}, // after v0.9.0-rc1 + {"v0.9.0", MinPolicyID, true, true}, + {"v0.9.1-0.20261001000000-abcdefabcdef", MinPolicyID, true, true}, // after v0.9.0 + {"v0.9.1", MinPolicyID, true, true}, + {"v0.10.0", MinPolicyID, true, true}, + {"v1.0.0", MinPolicyID, true, true}, + {"", MinPolicyID, false, false}, + {"(devel)", MinPolicyID, false, false}, + {"v0.0.0-20261001000000-abcdefabcdef", MinPolicyID, false, false}, // no tag before it + {"garbage", MinPolicyID, false, false}, } for _, tc := range cases { ok, known := AtLeast(tc.version, tc.min) assert.Equal(t, tc.ok, ok, "%s >= %s", tc.version, tc.min) assert.Equal(t, tc.known, known, "%s known", tc.version) } - ok, known := AtLeast(MinInlinePolicy, MinViolationSet) - assert.True(t, ok && known, "MinInlinePolicy must cover MinViolationSet") + ok, known := AtLeast(MinPolicyID, MinViolationSet) + assert.True(t, ok && known, "MinPolicyID must cover MinViolationSet") } func TestVersion(t *testing.T) { diff --git a/internal/policyview/policyview.go b/internal/policyview/policyview.go index b1976ac..545be3c 100644 --- a/internal/policyview/policyview.go +++ b/internal/policyview/policyview.go @@ -1,40 +1,23 @@ // Package policyview builds per-plugin working directories ("views") in which a plugin sees // an inline policy bundle at the exact relative path of the source the bundle extends (path -// shadowing, prototype). +// shadowing; see docs/configuration.md and ADR 0003). A plugin that keeps receiving the +// vendor's path string keeps the vendor's evidence streams, whatever agent library it was +// built with: // -// Plugins seed evidence UUIDs from the path string the agent passes them (policy-manager: -// policy_file = /, label _policy_path = ). A plugin that keeps receiving the -// vendor's path string therefore keeps the vendor's evidence streams, whatever agent library -// it was built with. A view makes that path string resolve to the inline bundle's tree: +// /.compliance-framework/policies// -> // (symlink) +// /.compliance-framework/policies///policies (real dir, inside the tree) // -// /.compliance-framework/policies// -> // (symlink) -// /.compliance-framework/policies///policies (real dir, inside the tree) +// The link sits at the path's parent because OPA loads nothing from a symlinked root. Every +// other entry of the agent's working directory is mirrored into the view's real directories +// as a symlink, so other relative paths resolve as they do for the agent. // -// The plugin process is started with the view as its working directory. The link sits at -// the path's parent, never at the leaf, because OPA's bundle loader loads nothing from a -// symlinked root directory but resolves a symlink earlier in the path like any other. +// Ownership (rule 1): view directories and shadow links are the agent's. A real entry where +// a mirror link would go was created by the plugin: Ensure keeps it, warns once and carries +// on. A real entry at a shadow link's own path is a conflict (*LinkConflictError). // -// Every other entry of the agent's working directory is mirrored into the view: each real -// directory of the view (the ancestors of the links) holds a symlink to every entry of the -// same directory in the agent's working directory that is not itself a view directory or a -// link. So every other relative path the plugin receives, or opens on its own, resolves as -// it does for the agent (reading and writing through the mirrored links), except new -// entries the plugin creates directly in a view directory, which stay in the view. -// -// Ownership (rule 1): the view directories and the shadow links are the agent's; any real -// (non-symlink) entry Ensure finds where it would put a mirror link was created by the -// plugin and is the plugin's. Ensure keeps it untouched (the plugin keeps seeing its own -// entry rather than the working directory's), warns once per view and name, and carries -// on: a plugin-owned entry never fails a run. A real entry at a shadow link's own path is a -// genuine conflict (the plugin would not see the bundle there), and Ensure fails with a -// *LinkConflictError. Plugin-owned entries go away with their view (GC), which never -// follows links. -// -// A view is content-addressed by its links and base: a new bundle revision is a new view, so -// nothing is ever swapped under a running plugin. Ensure is idempotent and only adds missing -// mirror links, so it can run before every plugin run. -// -// The package is a leaf: it imports only the standard library. +// Views are content-addressed by base and links, so a new bundle revision is a new view and +// nothing is swapped under a running plugin; Ensure is idempotent. The package imports only +// the standard library. package policyview import ( @@ -42,6 +25,7 @@ import ( "encoding/hex" "errors" "fmt" + "maps" "os" "path/filepath" "regexp" @@ -199,7 +183,7 @@ func DirFor(root, plugin, base string, links map[string]string) string { } h := sha256.New() _, _ = fmt.Fprintf(h, "%d:%s\n", len(base), base) - keys := sortedKeys(links) + keys := slices.Sorted(maps.Keys(links)) for _, k := range keys { _, _ = fmt.Fprintf(h, "%d:%s=%d:%s\n", len(k), k, len(links[k]), links[k]) } @@ -217,12 +201,12 @@ func (v View) Ensure() error { } dirs := viewDirs(v.Links) var errs []error - for _, d := range sortedKeys(dirs) { + for _, d := range slices.Sorted(maps.Keys(dirs)) { if err := os.MkdirAll(filepath.Join(v.Dir, filepath.FromSlash(d)), 0o755); err != nil { return fmt.Errorf("view %s: %w", v.Dir, err) } } - for _, link := range sortedKeys(v.Links) { + for _, link := range slices.Sorted(maps.Keys(v.Links)) { err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(link)), v.Links[link]) if errors.Is(err, errNotSymlink) { err = &LinkConflictError{View: v.Dir, Link: link} @@ -231,7 +215,7 @@ func (v View) Ensure() error { errs = append(errs, err) } } - for _, d := range sortedKeys(dirs) { + for _, d := range slices.Sorted(maps.Keys(dirs)) { entries, err := os.ReadDir(filepath.Join(v.Base, filepath.FromSlash(d))) if errors.Is(err, os.ErrNotExist) { continue @@ -394,12 +378,3 @@ func forgetWarnings(dir string) { return true }) } - -func sortedKeys[V any](m map[string]V) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - slices.Sort(keys) - return keys -} diff --git a/internal/policyview/policyview_test.go b/internal/policyview/policyview_test.go index e2fc53c..0e68659 100644 --- a/internal/policyview/policyview_test.go +++ b/internal/policyview/policyview_test.go @@ -34,7 +34,7 @@ func TestShadowable(t *testing.T) { func TestPlan(t *testing.T) { links, err := Plan([]string{oci}, []string{ - ".compliance-framework/policies/inline/custom/policies", // R82 inline path + ".compliance-framework/policies/_inline/custom/policies", // a bundle that is not shadowed ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies", // another tag: a mirrored sibling "/etc/ccf/policies", // absolute: unaffected }) @@ -63,7 +63,7 @@ func TestEnsure(t *testing.T) { } mk(oci+"/vendor.rego", "vendor") mk(".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies/old.rego", "old") - mk(".compliance-framework/policies/inline/custom/policies/x.rego", "inline") + mk(".compliance-framework/policies/_inline/custom/policies/x.rego", "inline") mk("config.yml", "cfg") target := filepath.Join(t.TempDir(), "b", "0123") require.NoError(t, os.MkdirAll(filepath.Join(target, "policies"), 0o755)) @@ -86,7 +86,7 @@ func TestEnsure(t *testing.T) { _, err := os.Stat(filepath.Join(v.Dir, oci, "vendor.rego")) assert.True(t, os.IsNotExist(err), "the vendor tree is hidden") assert.Equal(t, "old", read(".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies/old.rego")) - assert.Equal(t, "inline", read(".compliance-framework/policies/inline/custom/policies/x.rego")) + assert.Equal(t, "inline", read(".compliance-framework/policies/_inline/custom/policies/x.rego")) assert.Equal(t, "cfg", read("config.yml"), "other entries of the working directory are mirrored") info, err := os.Lstat(filepath.Join(v.Dir, oci)) require.NoError(t, err) From bce3ad327c75ae2a40a4131030909706d14b3492 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:53:18 -0300 Subject: [PATCH 35/47] fix: warn per plugin when an extends bundle is not shadowed (R88) Each enabled plugin that uses an extends bundle served at its own _inline path gets one policy-stream-forked warning for the bundle, with planShadowing's reason and the modules that would have kept the vendor streams at the source's path (inlinepolicy.UnshadowedForks). Override warnings now only flag what forks even at the source's path (a changed package or policy_id), so the two never repeat each other. Co-Authored-By: Claude Opus 5.5 --- cmd/inline.go | 1 + cmd/shadow.go | 64 +++++++++++++++++- cmd/shadow_test.go | 35 ++++++++-- docs/adr/0003-remote-config-overlay.md | 6 +- docs/configuration.md | 16 ++--- internal/inlinepolicy/identity.go | 89 ++++++++++++++++---------- internal/inlinepolicy/identity_test.go | 49 +++++++------- internal/inlinepolicy/streams_test.go | 30 +++++---- 8 files changed, 200 insertions(+), 90 deletions(-) diff --git a/cmd/inline.go b/cmd/inline.go index 45f2316..ad5e28a 100644 --- a/cmd/inline.go +++ b/cmd/inline.go @@ -125,6 +125,7 @@ func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Co for _, name := range slices.Sorted(maps.Keys(materialized)) { problems = append(problems, inlinepolicy.OverrideStreams(materialized[name])...) } + problems = append(problems, unshadowedWarnings(resolved, skip, plan, materialized)...) problems = append(problems, rc.policyIdentities(ctx, resolved, skip, materialized, origin)...) if agentconfig.HasPolicyErrors(problems) { return res, policyRejection(append(problems, res.warnings...)) diff --git a/cmd/shadow.go b/cmd/shadow.go index 3fe8715..8634a61 100644 --- a/cmd/shadow.go +++ b/cmd/shadow.go @@ -7,8 +7,10 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/compliance-framework/agent/internal/inlinepolicy" + "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/agent/internal/policyview" "github.com/compliance-framework/api/pkg/agentconfig" ) @@ -23,8 +25,8 @@ import ( // in policies/: every OCI source) and every enabled plugin that uses it can be given a view: // it does not also load the source or another bundle on the same path, and its other // relative paths still resolve in the view. Otherwise plugins receive the bundle at its own -// path under inlineLinksDir and its modules start path-based streams (R88; OverrideStreams -// warns). +// path under inlineLinksDir and its modules start path-based streams (R88; +// unshadowedWarnings says so per plugin). // shadowPlan is what prepareInline decided about shadowing, with the policy paths each // plugin receives for its non-inline entries (resolved once, reused for the views). @@ -51,7 +53,21 @@ func (rc *reconciler) viewsRoot() string { func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Config, skip map[string]string, refs map[string]bool) shadowPlan { plan := shadowPlan{shadow: map[string]bool{}, plugins: map[string]shadowPlugin{}, reasons: map[string]string{}} resolve := rc.boundedResolver() - if resolve == nil || rc.store == nil || !rc.store.Writable() || !inlinepolicy.SymlinksSupported(rc.viewsRoot()) { + unavailable := "" + switch { + case resolve == nil: + unavailable = "no policy resolver" + case rc.store == nil || !rc.store.Writable(): + unavailable = "plugin views need a writable state directory" + case !inlinepolicy.SymlinksSupported(rc.viewsRoot()): + unavailable = "plugin views need symlinks, which the file system does not support" + } + if unavailable != "" { + for name := range refs { + if b := resolved.PolicyBundles[name]; b != nil && b.Extends != nil { + plan.reasons[name] = unavailable + } + } return plan } extendsPath := map[string]string{} @@ -164,6 +180,48 @@ func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Co return plan } +// unshadowedWarnings warns, once per enabled plugin and inline bundle it uses, about an +// extends bundle that is not shadowed (R88): the plugin receives it at its own path, so the +// modules that would keep the vendor's evidence streams at the source's path +// (inlinepolicy.UnshadowedForks) start new ones. The reason is planShadowing's. +func unshadowedWarnings(resolved agentconfig.Config, skip map[string]string, plan shadowPlan, materialized map[string]*inlinepolicy.Materialized) []agentconfig.PolicyError { + var out []agentconfig.PolicyError + for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { + p := resolved.Plugins[pluginName] + if p == nil || !p.IsEnabled() { + continue + } + if _, skipped := skip[pluginName]; skipped { + continue + } + seen := map[string]bool{} + for _, e := range p.Policies { + name, ok := agentconfig.InlineBundleName(e) + if !ok || seen[name] || materialized[name] == nil { + continue + } + seen[name] = true + m := materialized[name] + forks := inlinepolicy.UnshadowedForks(m) + if len(forks) == 0 { + continue + } + why := plan.reasons[name] + if why == "" { + why = fmt.Sprintf("%s cannot be shadowed", m.ExtendsDir) + } + listed := forks + if len(listed) > 10 { + listed = append(slices.Clip(listed[:10]), fmt.Sprintf("and %d more", len(forks)-10)) + } + out = append(out, agentconfig.PolicyError{Bundle: name, Severity: agentconfig.SeverityWarning, Code: inlinepolicy.CodePolicyStreamForked, + Message: fmt.Sprintf("plugin %s receives bundle %s at %s, not at the path of %s (%s), so %d of its modules (%s) record their evidence in new streams instead of the vendor's; a module keeps its stream wherever it is loaded from with an authored policy_id (plugins built on agent ≥ %s)", + pluginName, name, m.Path, m.Extends.Source, why, len(forks), strings.Join(listed, ", "), pluginlib.MinPolicyID)}) + } + } + return out +} + // fallbackInlinePath is the path plugins receive for an inline bundle that is not shadowed, // or "" (an absolute tree directory, unaffected by views) without symlinks. func (rc *reconciler) fallbackInlinePath(name string) string { diff --git a/cmd/shadow_test.go b/cmd/shadow_test.go index c1a1ee6..c855b84 100644 --- a/cmd/shadow_test.go +++ b/cmd/shadow_test.go @@ -293,7 +293,8 @@ func TestShadow_PluginAlsoLoadingTheSourceFallsBack(t *testing.T) { t.Fatalf("an inherited module keeps the vendor bytes, got %q", raw) } forked := policyErrorsWithCode(h.remote.lastReport(t), inlinepolicy.CodePolicyStreamForked) - if len(forked) == 0 || forked[0].Path != "" || !strings.Contains(forked[0].Message, "inherited modules (banner.rego)") { + if len(forked) != 1 || forked[0].Path != "" || forked[0].Bundle != "ssh" || !strings.Contains(forked[0].Message, "plugin ssh receives bundle ssh") || + !strings.Contains(forked[0].Message, "also loads") || !strings.Contains(forked[0].Message, "2 of its modules (banner.rego, keys.rego)") { t.Fatalf("expected a policy-stream-forked warning for the inherited module, got %+v", h.remote.lastReport(t).PolicyErrors) } dups := policyErrorsWithCode(h.remote.lastReport(t), agentconfig.PolicyCodeDuplicatePolicyIdentity) @@ -320,10 +321,13 @@ func TestShadow_PluginAlsoLoadingTheSourceFallsBack(t *testing.T) { } // TestShadow_AbsoluteExtendsIsNotShadowed: a bundle extending an absolute path cannot be -// shadowed; an overlay editing it applies on an old plugin, its modules start new streams -// (warned about) and the plugin gets no view. +// shadowed; an overlay editing it applies on an old plugin, no plugin gets a view, and each +// plugin that uses it is warned, with the reason, that its modules start new streams. func TestShadow_AbsoluteExtendsIsNotShadowed(t *testing.T) { - h, _ := newInlineHarness(t) // the vendor is an absolute temp dir + h, vendor := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, ` policies: ["inline:ssh"]`, ` policies: ["inline:ssh"] + other: + source: ghcr.io/compliance-framework/plugin-other:v1 + policies: ["inline:ssh"]`, 1)) // the vendor is an absolute temp dir withPluginLib(h, oldLib) h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\ntitle := \"extra v2\"\n\nviolation[{\"id\": \"x\"}] if input.max > data.max\n"}}}}`) active := mustStartup(t, h.rc) @@ -331,10 +335,18 @@ func TestShadow_AbsoluteExtendsIsNotShadowed(t *testing.T) { if active.overlay == nil || r.Status != agentconfig.StatusApplied { t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) } - if forked := policyErrorsWithCode(r, inlinepolicy.CodePolicyStreamForked); len(forked) != 1 || forked[0].Severity != agentconfig.SeverityWarning { - t.Fatalf("expected one policy-stream-forked warning for the inherited module, got %+v", r.PolicyErrors) + forked := policyErrorsWithCode(r, inlinepolicy.CodePolicyStreamForked) + if len(forked) != 2 { + t.Fatalf("expected one policy-stream-forked warning per plugin, got %+v", r.PolicyErrors) + } + for i, plugin := range []string{"other", "ssh"} { + if e := forked[i]; e.Severity != agentconfig.SeverityWarning || e.Bundle != "ssh" || e.Path != "" || + !strings.Contains(e.Message, "plugin "+plugin+" receives bundle ssh") || !strings.Contains(e.Message, vendor+" is absolute") || + !strings.Contains(e.Message, "(banner.rego)") || !strings.Contains(e.Message, "policy_id") { + t.Fatalf("warning %d = %+v", i, e) + } } - if active.runtime.pluginViews["ssh"] != nil { + if len(active.runtime.pluginViews) != 0 { t.Fatal("an absolute extends path gets no view") } } @@ -560,6 +572,15 @@ func TestShadow_PlanDrops(t *testing.T) { if !strings.Contains(logs.String(), "Inline bundle is not shadowed") || !strings.Contains(logs.String(), tc.reason) { t.Fatalf("the reason must be logged (%q), got:\n%s", tc.reason, logs.String()) } + forked := policyErrorsWithCode(h.remote.lastReport(t), inlinepolicy.CodePolicyStreamForked) + if len(forked) == 0 { + t.Fatalf("expected policy-stream-forked warnings, got %+v", h.remote.lastReport(t).PolicyErrors) + } + for _, e := range forked { + if e.Path != "" || !strings.Contains(e.Message, "plugin ssh receives bundle "+e.Bundle) || !strings.Contains(e.Message, tc.reason) { + t.Fatalf("each unshadowed bundle's warning names the plugin and the reason: %+v", e) + } + } }) } } diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 5a1acd8..a617284 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -85,8 +85,8 @@ package and bundle-relative file without `policy_id`) is `duplicate-policy-ident introduces them (it gives the plugin a policy entry the file does not, or changes a bundle involved), warnings otherwise (R34), so an overlay never fails on the file's own duplicates; what is left of R66 (the same package with different identities) stays a warning. For an `extends` bundle, an override is compared with the vendor module it -replaces by the seeds plugins would compute from the extends source's path and the bundle's path: a changed -`package` is `policy-package-changed`, any other difference `policy-stream-forked` (warnings). +replaces by the seeds plugins would compute at the extends source's path: a changed `package` is +`policy-package-changed`, a different `policy_id` `policy-stream-forked` (warnings). ### Path shadowing (R83, R88) @@ -99,7 +99,7 @@ rejected the alternative of appending a continuity `policy_id` to every module t only worked for plugins rebuilt on agent ≥ v0.9.0 and gave added modules two identities depending on the shadowing decision. Where a view cannot represent the paths (absolute `extends`, a plugin loading the source and the bundle together, no symlinks) the bundle is given to plugins at its own `_inline` path and its modules start path-based -streams, with `policy-stream-forked` warnings. +streams; each plugin that uses it gets a `policy-stream-forked` warning with the reason. Risk: a plugin that relies on its working directory sees the view (mirrored, so reads and writes inside existing directories still reach the agent's; new top-level files stay in the view). Plugin contract (rule 1): plugins must not rely on creating new files relative to their working directory (use diff --git a/docs/configuration.md b/docs/configuration.md index 19dabf8..06e0149 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -308,14 +308,14 @@ policy_bundles: modules start path-based streams. That happens when the `extends` path is absolute (or not a `policies/` tree), when a plugin using the bundle also loads the source itself (reported as `duplicate-policy-identity` as before) or another bundle extending the same source, when another relative policy path of the plugin cannot be represented in - a view (e.g. a single-component path such as `policies`), or without symlinks. The agent logs why. -- **Overrides and evidence streams (R75).** The agent compares what plugins will seed each module of an `extends` - bundle with against the vendor module at the same path. A changed `package` is a `policy-package-changed` warning. - An override that does not continue the vendor stream (an own `policy_id`, a dropped vendor `policy_id`, or any - override of a bundle that is not shadowed) is a `policy-stream-forked` warning, which names the vendor's - `policy_id` when it has one; the inherited modules of a bundle that is not shadowed get one such warning for the - bundle. A module keeps the stream of a vendor module that declares a `policy_id` wherever it is loaded from, as - long as it keeps that `policy_id`. + a view (e.g. a single-component path such as `policies`), or without symlinks or a writable state directory. Each + plugin that uses such a bundle gets a `policy-stream-forked` warning for the bundle that gives the reason and lists + the modules that would have kept the vendor's streams at the source's path. An authored `policy_id` keeps a + module's stream wherever it is loaded from (plugins built on agent ≥ v0.9.0). +- **Overrides and evidence streams (R75).** An override is compared with the vendor module it replaces by the seeds + plugins compute at the source's path. A changed `package` is a `policy-package-changed` warning; a `policy_id` that + differs from the vendor's (an own one, or a dropped vendor one) is a `policy-stream-forked` warning, which names the + vendor's `policy_id` when it has one. - **Plugin views (rule 1).** A plugin running in a view sees its working directory as the view: files it creates there (rather than through a mirrored directory) stay in the view and are removed with it. **Plugin contract:** plugins must not rely on creating new files or directories relative to their working diff --git a/internal/inlinepolicy/identity.go b/internal/inlinepolicy/identity.go index 188f2fa..d551bc3 100644 --- a/internal/inlinepolicy/identity.go +++ b/internal/inlinepolicy/identity.go @@ -109,44 +109,33 @@ func SeedOf(id ModuleIdentity, pluginPath string) (string, string) { return policyeval.SeedPath(id.PolicyID, file, pluginPath) } -// OverrideStreams warns about the modules of an extends bundle that do not keep the evidence -// stream of the vendor module at the same path (R75), comparing what plugins seed with: the -// vendor module loaded from the extends source and the module loaded from the bundle's path. -// An override that changes the package is policy-package-changed; any other override, and -// the inherited modules of a bundle plugins receive at its own path (not shadowed), are -// policy-stream-forked. A shadowed bundle keeps every stream its modules keep the package -// of. +// OverrideStreams warns about the authored overrides of an extends bundle that do not keep +// the evidence stream of the vendor module they replace (R75) even when plugins receive the +// bundle at the extends source's path (shadowed), comparing the seeds plugins compute there: +// a changed package is policy-package-changed, a policy_id that differs from the vendor's +// (an own one, or a dropped vendor one) is policy-stream-forked. What not being shadowed +// costs on top of that is UnshadowedForks. func OverrideStreams(m *Materialized) []agentconfig.PolicyError { if m == nil || m.Extends == nil { return nil } - vendor := map[string]ModuleIdentity{} - for _, id := range m.ExtendsIdentities { - vendor[id.Path] = id - } + vendor := m.vendorModules() var out []agentconfig.PolicyError - warn := func(p, code, format string, args ...any) { - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: p, Severity: agentconfig.SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)}) - } - var inherited []string for _, id := range m.Identities { v, replaced := vendor[id.Path] - if !replaced { + if !replaced || !m.Authored[id.Path] { continue } - if m.Authored[id.Path] && id.Package != v.Package { - warn(id.Path, agentconfig.PolicyCodePolicyPackageChanged, + warn := func(code, format string, args ...any) { + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: id.Path, Severity: agentconfig.SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)}) + } + if id.Package != v.Package { + warn(agentconfig.PolicyCodePolicyPackageChanged, "the override of %s changes its package from %s to %s, which starts a new evidence stream for it; keep `package %s` to continue the vendor policy's stream", id.Path, v.Package, id.Package, v.Package) continue } - vendorFile, vendorPath := SeedOf(v, m.ExtendsDir) - file, policyPath := SeedOf(id, m.Path) - if file == vendorFile && policyPath == vendorPath { - continue - } - if !m.Authored[id.Path] { - inherited = append(inherited, id.Path) + if sameSeed(id, v, m.ExtendsDir, m.ExtendsDir) { continue } got := "no policy_id" @@ -157,20 +146,52 @@ func OverrideStreams(m *Materialized) []agentconfig.PolicyError { if v.PolicyID != "" { hint = fmt.Sprintf("; declare `policy_id := %q` to continue the vendor policy's stream", v.PolicyID) } - warn(id.Path, CodePolicyStreamForked, - "the override of %s has %s, so plugins that load this bundle instead of %s record its evidence in a new stream%s", - id.Path, got, m.Extends.Source, hint) + warn(CodePolicyStreamForked, + "the override of %s has %s, so plugins record its evidence in a new stream, not the stream of %s in %s%s", + id.Path, got, id.Path, m.Extends.Source, hint) } - if len(inherited) > 0 { - warn("", CodePolicyStreamForked, - "bundle %s is not shadowed, so plugins receive it at %s instead of the path of %s and record the evidence of its inherited modules (%s) in new streams", - m.Name, m.Path, m.Extends.Source, strings.Join(inherited, ", ")) + return out +} + +// UnshadowedForks returns the paths of the modules of m that keep the evidence stream of the +// vendor module at their path when plugins receive m at the extends source's path, but not +// at m.Path, where they do when m is not shadowed: inherited modules and overrides that keep +// the vendor's package and policy_id, unless that policy_id makes the stream +// location-independent. Nil when m is shadowed or extends nothing. +func UnshadowedForks(m *Materialized) []string { + if m == nil || m.Extends == nil || m.Shadowed { + return nil + } + vendor := m.vendorModules() + var out []string + for _, id := range m.Identities { + v, ok := vendor[id.Path] + if ok && id.Package == v.Package && sameSeed(id, v, m.ExtendsDir, m.ExtendsDir) && !sameSeed(id, v, m.Path, m.ExtendsDir) { + out = append(out, id.Path) + } } return out } -// CodePolicyStreamForked is the PolicyError code of a module of an extends bundle that does -// not continue the evidence stream of the vendor module at its path (R75). Warning. +func (m *Materialized) vendorModules() map[string]ModuleIdentity { + vendor := make(map[string]ModuleIdentity, len(m.ExtendsIdentities)) + for _, id := range m.ExtendsIdentities { + vendor[id.Path] = id + } + return vendor +} + +// sameSeed reports whether module id loaded from path seeds evidence like vendor module v +// loaded from vendorPath. +func sameSeed(id, v ModuleIdentity, path, vendorPath string) bool { + file, policyPath := SeedOf(id, path) + vendorFile, vendorPolicyPath := SeedOf(v, vendorPath) + return file == vendorFile && policyPath == vendorPolicyPath +} + +// CodePolicyStreamForked is the PolicyError code of modules of an extends bundle that do not +// continue the evidence stream of the vendor module at their path (R75, R88): an override +// with another policy_id, or the modules of a bundle that is not shadowed. Warning. const CodePolicyStreamForked = "policy-stream-forked" // parseRego parses a module as Rego v1, then as Rego v0, as plugins on either OPA major diff --git a/internal/inlinepolicy/identity_test.go b/internal/inlinepolicy/identity_test.go index c7b4b82..9d56dff 100644 --- a/internal/inlinepolicy/identity_test.go +++ b/internal/inlinepolicy/identity_test.go @@ -54,9 +54,10 @@ func TestAuthoredSites(t *testing.T) { assert.Equal(t, []Site{{Path: "set.rego", Row: 5, Col: 1}}, ids) } -// TestOverrideStreams_R75: an override of a bundle plugins receive at its own path continues -// the vendor module's stream only with the vendor's package and either the vendor's -// policy_id or, when the vendor has none, the vendor's policy file as its policy_id. +// TestOverrideStreams_R75: an override continues the vendor module's stream at the vendor's +// path only with the vendor's package and the vendor's policy_id (or one that names the +// vendor's policy file). Served at the bundle's own path (not shadowed), the modules that do +// are UnshadowedForks. func TestOverrideStreams_R75(t *testing.T) { const vendorID = "package compliance_framework.ided\n\nimport rego.v1\n\npolicy_id := \"vendor-id\"\n\ntitle := \"x\"\n" dir, resolve := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth, "ided.rego": vendorID}) @@ -65,14 +66,15 @@ func TestOverrideStreams_R75(t *testing.T) { name string modules map[string]string want map[string]string // path -> code + forks []string }{ - {"continues the legacy stream", map[string]string{"banner.rego": continuing}, map[string]string{}}, - {"keeps the vendor policy_id", map[string]string{"ided.rego": vendorID + "\n# changed\n"}, map[string]string{}}, - {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{"banner.rego": CodePolicyStreamForked}}, - {"changed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\npolicy_id := \"other\"\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, - {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}}, - {"changed package", map[string]string{"max_auth.rego": "package compliance_framework.max_auth_v2\n\ntitle := \"x\"\n"}, map[string]string{"max_auth.rego": agentconfig.PolicyCodePolicyPackageChanged}}, - {"new module", map[string]string{"new.rego": "package compliance_framework.new\n\ntitle := \"x\"\n"}, map[string]string{}}, + {"continues the legacy stream", map[string]string{"banner.rego": continuing}, map[string]string{}, []string{"max_auth.rego"}}, + {"keeps the vendor policy_id", map[string]string{"ided.rego": vendorID + "\n# changed\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, + {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, + {"changed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\npolicy_id := \"other\"\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}, []string{"banner.rego", "max_auth.rego"}}, + {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}, []string{"banner.rego", "max_auth.rego"}}, + {"changed package", map[string]string{"max_auth.rego": "package compliance_framework.max_auth_v2\n\ntitle := \"x\"\n"}, map[string]string{"max_auth.rego": agentconfig.PolicyCodePolicyPackageChanged}, []string{"banner.rego"}}, + {"new module", map[string]string{"new.rego": "package compliance_framework.new\n\ntitle := \"x\"\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -82,22 +84,23 @@ func TestOverrideStreams_R75(t *testing.T) { for _, e := range OverrideStreams(m) { require.Equal(t, agentconfig.SeverityWarning, e.Severity) require.Equal(t, "b", e.Bundle) - if e.Path != "" { // the inherited modules' warning - got[e.Path] = e.Code - } + got[e.Path] = e.Code } assert.Equal(t, tc.want, got) + assert.Equal(t, tc.forks, UnshadowedForks(m)) }) } m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Modules: map[string]string{"banner.rego": vendorBanner}}, resolve) require.NoError(t, err) assert.Empty(t, OverrideStreams(m), "a bundle without extends overrides nothing") + assert.Empty(t, UnshadowedForks(m)) } -// TestOverrideStreams_NonCleanLocalSource: for a local source configured with a non-clean -// path, an authored policy_id that is the literal "/" continues the -// stream; the cleaned join does not (plugins label _policy_path with the literal path). -func TestOverrideStreams_NonCleanLocalSource(t *testing.T) { +// TestUnshadowedForks_NonCleanLocalSource: for a local source configured with a non-clean +// path, an authored policy_id that is the literal "/" keeps the stream at +// the bundle's own path; the cleaned join does not (plugins label _policy_path with the +// literal path). +func TestUnshadowedForks_NonCleanLocalSource(t *testing.T) { for _, pluginPath := range []string{"./policies", "./policies/", "policies/"} { t.Run(pluginPath, func(t *testing.T) { t.Chdir(t.TempDir()) @@ -105,18 +108,16 @@ func TestOverrideStreams_NonCleanLocalSource(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join("policies", "banner.rego"), []byte(vendorBanner), 0o644)) resolve := func(_ context.Context, source string) (string, error) { return pluginPath, nil } - override := func(id string) []agentconfig.PolicyError { + override := func(id string) *Materialized { t.Helper() src := "package compliance_framework.banner\n\npolicy_id := \"" + id + "\"\n\ntitle := \"Banner\"\n" m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("local"), Modules: map[string]string{"banner.rego": src}}, resolve) require.NoError(t, err) - return OverrideStreams(m) + assert.Empty(t, OverrideStreams(m), "both continue the stream at the vendor's path") + return m } - assert.Empty(t, override(pluginPath+"/banner.rego"), "the literal path continues the stream") - - forked := override("policies/banner.rego") - require.Len(t, forked, 1, "the cleaned path seeds a different _policy_path") - assert.Equal(t, CodePolicyStreamForked, forked[0].Code) + assert.Empty(t, UnshadowedForks(override(pluginPath+"/banner.rego")), "the literal path continues the stream anywhere") + assert.Equal(t, []string{"banner.rego"}, UnshadowedForks(override("policies/banner.rego")), "the cleaned path seeds a different _policy_path") }) } } diff --git a/internal/inlinepolicy/streams_test.go b/internal/inlinepolicy/streams_test.go index 4bae0be..2ffff04 100644 --- a/internal/inlinepolicy/streams_test.go +++ b/internal/inlinepolicy/streams_test.go @@ -18,7 +18,7 @@ import ( // Evidence streams of an inline bundle that extends a vendor bundle: shadowed, plugins // receive the vendor's path and every module that keeps its package keeps its stream; not // shadowed, plugins receive the bundle's own path and its modules start path-based streams -// (R88), which OverrideStreams warns about. +// (R88), which UnshadowedForks lists. const ( // streamsVendor is where the agent extracts the vendor OCI bundle: relative to the @@ -109,9 +109,9 @@ func shadowView(t *testing.T, m *Materialized) string { // TestStreams_UnshadowedBundleStartsPathStreams: a bundle plugins receive at its own path // keeps the vendor files as they are (no policy_id is added), so its inherited modules start -// path-based streams under the relative _inline path, which is warned about once; a module -// whose vendor package declares a policy_id keeps the vendor stream. Another revision keeps -// the path and the streams (R67). +// path-based streams under the relative _inline path (UnshadowedForks); a module whose +// vendor package declares a policy_id keeps the vendor stream. Another revision keeps the +// path and the streams (R67). func TestStreams_UnshadowedBundleStartsPathStreams(t *testing.T) { l, resolve := streamsSetup(t) m := streamsMaterialize(t, l, resolve, map[string]string{ @@ -137,12 +137,8 @@ func TestStreams_UnshadowedBundleStartsPathStreams(t *testing.T) { assert.Equal(t, vendor["compliance_framework.deny_root_login"].uuid, got["compliance_framework.deny_root_login"].uuid, "the vendor's own policy_id keeps the stream") - warnings := OverrideStreams(m) - require.Len(t, warnings, 1, "one warning for the inherited modules: %+v", warnings) - assert.Equal(t, CodePolicyStreamForked, warnings[0].Code) - assert.Equal(t, agentconfig.SeverityWarning, warnings[0].Severity) - assert.Empty(t, warnings[0].Path) - assert.Contains(t, warnings[0].Message, "inherited modules (banner.rego, ssh/require_key_based_ssh.rego)") + assert.Empty(t, OverrideStreams(m), "no override") + assert.Equal(t, []string{"banner.rego", "ssh/require_key_based_ssh.rego"}, UnshadowedForks(m)) // The report inventories the tree as written; the vendor files stay in the extends report. assert.Equal(t, sha(streamsVendorFiles["banner.rego"]), fileSHA(m.Extends.Files, "banner.rego")) @@ -174,6 +170,7 @@ func TestStreams_Shadowed(t *testing.T) { assert.True(t, m.Shadowed) assert.Equal(t, streamsVendor, m.Path, "plugins receive the vendor's path string") assert.Empty(t, OverrideStreams(m)) + assert.Empty(t, UnshadowedForks(m)) wd, err := os.Getwd() require.NoError(t, err) @@ -193,7 +190,8 @@ func TestStreams_Shadowed(t *testing.T) { assert.Equal(t, streamsLinks+"/custom/policies", filepath.ToSlash(m.Path)) } -// TestStreams_Overrides: the R75 warnings about overrides, shadowed or not. +// TestStreams_Overrides: the R75 warnings about overrides, shadowed or not, and what not +// being shadowed costs. func TestStreams_Overrides(t *testing.T) { const keyBased = "ssh/require_key_based_ssh.rego" const rootLogin = "ssh/deny_root_login.rego" @@ -212,6 +210,7 @@ func TestStreams_Overrides(t *testing.T) { modules map[string]string want map[string]string // path -> code hint string // a policy_id the warning suggests + forks []string // UnshadowedForks }{ { name: "shadowed, same package", @@ -241,12 +240,20 @@ func TestStreams_Overrides(t *testing.T) { { name: "not shadowed, same package", modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, + want: map[string]string{}, + forks: []string{"banner.rego", keyBased}, + }, + { + name: "not shadowed, own policy_id", + modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\npolicy_id := \"ssh-key-based\"\n\ntitle := \"tuned\"\n"}, want: map[string]string{keyBased: CodePolicyStreamForked}, + forks: []string{"banner.rego"}, }, { name: "not shadowed, vendor policy_id kept", modules: map[string]string{rootLogin: "package compliance_framework.deny_root_login\n\nimport rego.v1\n\npolicy_id := \"ssh-deny-root-login\"\n\ntitle := \"tuned\"\n"}, want: map[string]string{}, + forks: []string{"banner.rego", keyBased}, }, } { t.Run(tc.name, func(t *testing.T) { @@ -255,6 +262,7 @@ func TestStreams_Overrides(t *testing.T) { require.Equal(t, tc.shadow, m.Shadowed) warnings := OverrideStreams(m) assert.Equal(t, tc.want, codes(warnings)) + assert.Equal(t, tc.forks, UnshadowedForks(m)) for _, w := range warnings { assert.Equal(t, agentconfig.SeverityWarning, w.Severity) if tc.hint != "" && w.Path != "" { From e8afe722e5bad4b2ab3720acef1921ac95c21cfe Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:17:48 -0300 Subject: [PATCH 36/47] fix: re-report a remembered rejection after a restart startup skipped a fetched overlay remembered as rejected but set neither the attempted revision nor the outcome, so with a 304 the API showed the instance as pending until a new revision. Rebuild the outcome from the persisted RejectedRecord (now also keeping Unsafe and up to 100 PolicyErrors, optional fields), and keep it as the outcome when the applied overlay is re-prepared. --- cmd/reconciler.go | 47 +++++++++++++++++-- cmd/remote_test.go | 65 ++++++++++++++++++++++++++ docs/adr/0003-remote-config-overlay.md | 4 +- docs/configuration.md | 2 + internal/agentstate/cache.go | 6 +++ 5 files changed, 120 insertions(+), 4 deletions(-) diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 7139c94..8997c2e 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -10,6 +10,7 @@ import ( "fmt" "math/rand" "os" + "slices" "strings" "sync" "sync/atomic" @@ -47,6 +48,10 @@ var ( prepareNetworkTimeout = 5 * time.Minute ) +// maxRememberedPolicyErrors bounds the policy errors persisted with a rejection (the cache is +// rewritten on every fetch); the report after a restart lists at most this many. +const maxRememberedPolicyErrors = 100 + // 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. @@ -412,6 +417,12 @@ func (rc *reconciler) startup(ctx context.Context) (*candidate, error) { } 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 } @@ -476,6 +487,31 @@ func (rc *reconciler) rememberedRejected(rec *agentstate.OverlayRecord) bool { 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), + PolicyErrors: slices.Clone(r.PolicyErrors), + } + 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) { @@ -496,6 +532,8 @@ func (rc *reconciler) recordFailure(target *agentstate.OverlayRecord, aerr *appl Status: aerr.Status, Reason: aerr.Reason, Error: msg, + Unsafe: aerr.Unsafe, + PolicyErrors: aerr.PolicyErrors[:min(len(aerr.PolicyErrors), maxRememberedPolicyErrors)], } rc.saveCache() return @@ -967,6 +1005,9 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { 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) @@ -997,16 +1038,16 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { rc.cache.Applied = target rc.saveCache() } - rc.lastOutcome = nil + 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), nil) + rc.maybeReport(ctx, rc.adopt(active, cand), rc.lastOutcome) return } rc.logger.Info("Applying the new configuration", "revision", revisionForLog(target)) rc.swap(cand) rc.gcInline(rc.running(), active, cand) - rc.maybeReport(ctx, cand, nil) + rc.maybeReport(ctx, cand, rc.lastOutcome) } func revisionForLog(ov *agentstate.OverlayRecord) any { diff --git a/cmd/remote_test.go b/cmd/remote_test.go index f1da67f..a75e4e3 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "runtime" "strings" "sync" @@ -697,6 +698,70 @@ func TestApply_RejectedRevisionMemory(t *testing.T) { } } +// 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", "")) diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index a617284..d96c4f7 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -160,7 +160,9 @@ The overlay ETag is opaque (`"r-"`). The agent stores the raw header 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. +revision + sha256 of the overlay instead, so one rejection never blocks later revisions. The remembered rejection keeps +its status, reason, error, unsafe changes and (up to 100) policy errors, so the agent re-reports it after a restart +and while the fetch keeps answering 304. ### File-origin tolerance (R34) diff --git a/docs/configuration.md b/docs/configuration.md index 06e0149..159a808 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -459,6 +459,8 @@ in-flight plugin runs get up to 5 minutes to finish (R33). Evidence produced und 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 and policy +errors) is reported again, so the instance still shows as rejected after a restart. ## State directory and instance ID diff --git a/internal/agentstate/cache.go b/internal/agentstate/cache.go index 33380de..3258bb6 100644 --- a/internal/agentstate/cache.go +++ b/internal/agentstate/cache.go @@ -9,6 +9,8 @@ import ( "os" "path/filepath" "time" + + "github.com/compliance-framework/api/pkg/agentconfig" ) const ( @@ -51,6 +53,10 @@ type RejectedRecord struct { Status string `json:"status"` Reason string `json:"reason"` Error string `json:"error"` + // Unsafe and PolicyErrors complete the outcome re-reported after a restart. They are + // optional: caches written before they existed load (and checksum) unchanged. + Unsafe []agentconfig.Change `json:"unsafe,omitempty"` + PolicyErrors []agentconfig.PolicyError `json:"policy_errors,omitempty"` } // Cache is the persisted remote configuration state (0600, it may hold values an admin typed). From 81ec07024f95d4f08c39c88b05d8bbdf3cc266d7 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 06:20:56 -0300 Subject: [PATCH 37/47] refactor: drop authored policy_id (R74); path shadowing keeps vendor streams The API dropped authored policy_id (api#465): path shadowing already keeps vendor evidence streams with every plugin build, so a declared identity is no longer needed. - policy-manager: evidence seeding and labels are back to main's (no SeedPath branch, no _policy_id label). The seed golden test moves to evidence_seed_test.go; the policy_id tests are removed. - inlinepolicy: ModuleIdentity has no PolicyID; SeedOf is the plain (plugin path joined with the module path, plugin path) pair, which is what SeedPath returned without an id. OverrideStreams only reports policy-package-changed; policy-stream-forked is the unshadowed-bundle warning alone. Materialized.PolicyIDRules is gone. - cmd: no duplicate-policy-id (duplicate-policy-identity stays), no plugin-lib-policy-id-unsupported; the plugin library check is the set-form violation gate (MinViolationSet, v0.7.1) alone. - pluginlib: MinPolicyID is removed. - Docs and AGENTS.md no longer describe policy_id. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 11 +- cmd/compat.go | 58 +++--- cmd/compat_test.go | 88 ++++----- cmd/identity.go | 26 +-- cmd/inline_test.go | 33 ---- cmd/shadow.go | 5 +- cmd/shadow_test.go | 15 +- docs/adr/0003-remote-config-overlay.md | 36 ++-- docs/configuration.md | 65 ++----- docs/policy_artifacts.md | 9 - internal/inlinepolicy/identity.go | 101 ++++------- internal/inlinepolicy/identity_test.go | 84 ++------- internal/inlinepolicy/materialize.go | 7 +- internal/inlinepolicy/streams_test.go | 47 ++--- internal/pluginlib/pluginlib.go | 10 +- internal/pluginlib/pluginlib_test.go | 35 ++-- policy-manager/evidence_seed_test.go | 46 +++++ policy-manager/policy-manager.go | 67 ++----- policy-manager/policy-manager_test.go | 2 +- policy-manager/policy_id_test.go | 237 ------------------------- 20 files changed, 240 insertions(+), 742 deletions(-) create mode 100644 policy-manager/evidence_seed_test.go delete mode 100644 policy-manager/policy_id_test.go diff --git a/AGENTS.md b/AGENTS.md index e13dd11..a1f2f67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,13 +115,12 @@ change here must keep working with them. - **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 the seed of a policy without `policy_id`; `policy_id` changes it only through the - API's `policyeval.SeedPath`. The golden test in `policy-manager/policy_id_test.go` pins the old UUIDs. + field compute it. Never change it: path shadowing keeps vendor streams only because the seed is the path string. + The golden test in `policy-manager/evidence_seed_test.go` pins the UUIDs. - **Plugin library checks.** Inline policies work with every plugin build (path shadowing keeps vendor evidence - streams). Only two things depend on the plugin's agent library (`internal/pluginlib`): an overlay-introduced - set-form `violation contains` is rejected below `MinViolationSet` (v0.7.1), and an authored `policy_id` warns below - `MinPolicyID` (v0.9.0, the first release with `policy_id` seeding; update it before tagging if agent#95 ships in a - different release). Versions compare as semver, so pre-releases of a minimum are older. + streams). Only one thing depends on the plugin's agent library (`internal/pluginlib`): an overlay-introduced + set-form `violation contains` is rejected below `MinViolationSet` (v0.7.1). Versions compare as semver, so + pre-releases of a minimum are older. - **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/cmd/compat.go b/cmd/compat.go index 9b10bc5..fb856db 100644 --- a/cmd/compat.go +++ b/cmd/compat.go @@ -14,15 +14,10 @@ import ( // Plugin compatibility (R76, R88). A plugin evaluates policies with the policy-manager it // embeds, so what it can do with an inline bundle depends on the agent library it was built // with, which the agent reads from the plugin binary's build info. Path shadowing -// (shadow.go) keeps vendor evidence streams with any library, so only two things depend on -// it: -// -// - a set-form violation (`violation contains ...`) crashes plugins older than -// pluginlib.MinViolationSet: an error when the overlay introduces it -// (plugin-lib-violation-set-unsupported); -// - a policy_id an authored module declares is ignored by plugins older than -// pluginlib.MinPolicyID, so the module's stream is path-based: a warning -// (plugin-lib-policy-id-unsupported). +// (shadow.go) keeps vendor evidence streams with any library, so only one thing depends on +// it: a set-form violation (`violation contains ...`) crashes plugins older than +// pluginlib.MinViolationSet, an error when the overlay introduces it +// (plugin-lib-violation-set-unsupported). // // Inline bundles from the config file only warn (R34), and so does a library whose version // is unknown (a local or replaced build, or no build info). The plugins report carries each @@ -51,8 +46,8 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon } } reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version}) - if ok, _ := pluginlib.AtLeast(version, pluginlib.MinPolicyID); ok { - continue // policy_id and set-form violations both work + if ok, _ := pluginlib.AtLeast(version, pluginlib.MinViolationSet); ok { + continue // set-form violations work } var bundles []*inlinepolicy.Materialized @@ -77,7 +72,7 @@ func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentCon } // libProblems are the problems of one plugin whose library is older than -// pluginlib.MinPolicyID or unknown. overlay is whether the overlay brought the plugin and +// pluginlib.MinViolationSet or unknown. overlay is whether the overlay brought the plugin and // these bundles together; only then is a known incompatibility an error. func libProblems(plugin, version string, bundles []*inlinepolicy.Materialized, overlay bool) []agentconfig.PolicyError { var out []agentconfig.PolicyError @@ -85,37 +80,28 @@ func libProblems(plugin, version string, bundles []*inlinepolicy.Materialized, o if lib == "" { lib = "unknown" } - idOK, known := pluginlib.AtLeast(version, pluginlib.MinPolicyID) - setOK, setKnown := pluginlib.AtLeast(version, pluginlib.MinViolationSet) - severity := func(known bool) string { - if known && overlay { - return agentconfig.SeverityError - } - return agentconfig.SeverityWarning + ok, known := pluginlib.AtLeast(version, pluginlib.MinViolationSet) + if ok { + return nil + } + severity := agentconfig.SeverityWarning + if known && overlay { + severity = agentconfig.SeverityError } unknownWhy := "is unknown (a local or replaced build, or no build info)" if version != "" { unknownWhy = fmt.Sprintf("%s has no release before it", version) } for _, m := range bundles { - if !setOK { - for _, s := range m.SetViolations { - msg := fmt.Sprintf("plugin %s (agent lib %s) cannot evaluate violation as a set (`violation contains ...` needs agent ≥ %s) and would crash; use `violation[{...}] if { … }`", - plugin, lib, pluginlib.MinViolationSet) - if !setKnown { - msg = fmt.Sprintf("plugin %s: its agent library version %s; plugins built on agent < %s crash on `violation contains ...`; use `violation[{...}] if { … }`", - plugin, unknownWhy, pluginlib.MinViolationSet) - } - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: severity(setKnown), - Code: agentconfig.PolicyCodePluginLibViolationSetUnsupported, Message: msg}) - } - } - if !idOK && known { - for _, s := range m.PolicyIDRules { - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: agentconfig.SeverityWarning, - Code: agentconfig.PolicyCodePluginLibPolicyIDUnsupported, - Message: fmt.Sprintf("plugin %s (agent lib %s) ignores policy_id; this module's evidence stream follows its path", plugin, lib)}) + for _, s := range m.SetViolations { + msg := fmt.Sprintf("plugin %s (agent lib %s) cannot evaluate violation as a set (`violation contains ...` needs agent ≥ %s) and would crash; use `violation[{...}] if { … }`", + plugin, lib, pluginlib.MinViolationSet) + if !known { + msg = fmt.Sprintf("plugin %s: its agent library version %s; plugins built on agent < %s crash on `violation contains ...`; use `violation[{...}] if { … }`", + plugin, unknownWhy, pluginlib.MinViolationSet) } + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: severity, + Code: agentconfig.PolicyCodePluginLibViolationSetUnsupported, Message: msg}) } } return out diff --git a/cmd/compat_test.go b/cmd/compat_test.go index 7fd80e0..f49a862 100644 --- a/cmd/compat_test.go +++ b/cmd/compat_test.go @@ -10,7 +10,7 @@ import ( ) // Plugin compatibility (R76, R88): the plugin's agent library decides whether it can -// evaluate set-form violations, and whether it honours an authored policy_id. +// evaluate set-form violations. // withPluginLib makes the harness's plugins report version as their agent library. func withPluginLib(h *remoteHarness, version string) { @@ -44,12 +44,8 @@ func rejectionErrors(r agentconfig.Report, code string) []agentconfig.PolicyErro } func TestCompat(t *testing.T) { - const ( - set = agentconfig.PolicyCodePluginLibViolationSetUnsupported - id = agentconfig.PolicyCodePluginLibPolicyIDUnsupported - ) + const set = agentconfig.PolicyCodePluginLibViolationSetUnsupported vendorPolicies := strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1"]`, 1) - withPolicyID := strings.Replace(inlineBaseConfig, " title := \"extra\"\n", " policy_id := \"extra\"\n\n title := \"extra\"\n", 1) trusted := strings.Replace(inlineBaseConfig, "mode: apply_safe", "mode: apply_safe\n trusted_sources: [\"ghcr.io/compliance-framework/*\"]", 1) for _, tc := range []struct { name string @@ -57,18 +53,18 @@ func TestCompat(t *testing.T) { libs map[string]string // plugin source -> lib version; "*" for any other overlay string applied bool - want map[string]string // code -> severity of its problems at extra.rego ("" = none) + want string // severity of the set-form problem at extra.rego ("" = none) }{ { name: "overlay set form, lib v0.1.9", libs: map[string]string{"*": oldLib}, overlay: inlineOverlay, - want: map[string]string{set: agentconfig.SeverityError}, + want: agentconfig.SeverityError, }, { - name: "overlay set form, lib v0.9.0", libs: map[string]string{"*": "v0.9.0"}, overlay: inlineOverlay, applied: true, + name: "overlay set form, lib v0.7.1", libs: map[string]string{"*": "v0.7.1"}, overlay: inlineOverlay, applied: true, }, { name: "overlay set form, unknown lib", libs: map[string]string{"*": ""}, overlay: inlineOverlay, applied: true, - want: map[string]string{set: agentconfig.SeverityWarning}, + want: agentconfig.SeverityWarning, }, { name: "overlay assigns a set-form bundle, lib v0.7.2", config: vendorPolicies, libs: map[string]string{"*": "v0.7.2"}, @@ -77,22 +73,17 @@ func TestCompat(t *testing.T) { { name: "overlay assigns a set-form bundle, lib v0.7.0", config: vendorPolicies, libs: map[string]string{"*": "v0.7.0"}, overlay: `{"plugins":{"ssh":{"policies":["inline:ssh"]}}}`, - want: map[string]string{set: agentconfig.SeverityError}, + want: agentconfig.SeverityError, }, { name: "overlay moves the plugin to an old build", config: trusted, libs: map[string]string{"ghcr.io/compliance-framework/plugin-ssh:v0": "v0.7.0", "*": "v0.9.0"}, overlay: `{"plugins":{"ssh":{"source":"ghcr.io/compliance-framework/plugin-ssh:v0"}}}`, - want: map[string]string{set: agentconfig.SeverityError}, + want: agentconfig.SeverityError, }, { - name: "file policy_id and set form, lib v0.7.0", config: withPolicyID, libs: map[string]string{"*": "v0.7.0"}, applied: true, - want: map[string]string{set: agentconfig.SeverityWarning, id: agentconfig.SeverityWarning}, - }, - { - name: "overlay policy_id, lib v0.8.1", libs: map[string]string{"*": "v0.8.1"}, applied: true, - overlay: `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\npolicy_id := \"extra\"\n\ntitle := \"extra v2\"\n\nviolation[{\"id\": \"x\"}] if input.max > data.max\n"}}}}`, - want: map[string]string{id: agentconfig.SeverityWarning}, + name: "file set form, lib v0.7.0", libs: map[string]string{"*": "v0.7.0"}, applied: true, + want: agentconfig.SeverityWarning, }, } { t.Run(tc.name, func(t *testing.T) { @@ -121,20 +112,18 @@ func TestCompat(t *testing.T) { case !tc.applied && (active.overlay != nil || r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors): t.Fatalf("expected rejected/policy-errors, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) } - for _, code := range []string{set, id} { - got := policyErrorsWithCode(r, code) - if tc.want[code] == agentconfig.SeverityError { - got = rejectionErrors(r, code) - } - switch { - case tc.want[code] == "" && len(got) != 0: - t.Fatalf("no %s expected, got %+v", code, got) - case tc.want[code] == "": - case len(got) != 1 || got[0].Severity != tc.want[code] || got[0].Path != "extra.rego" || got[0].Row == 0 || !strings.Contains(got[0].Message, "plugin ssh"): - t.Fatalf("expected one located %s %s naming the plugin, got %+v", tc.want[code], code, r.PolicyErrors) - case code == set && !strings.Contains(got[0].Message, "violation[{...}] if"): - t.Fatalf("the set-form problem must name the fix: %s", got[0].Message) - } + got := policyErrorsWithCode(r, set) + if tc.want == agentconfig.SeverityError { + got = rejectionErrors(r, set) + } + switch { + case tc.want == "" && len(got) != 0: + t.Fatalf("no %s expected, got %+v", set, got) + case tc.want == "": + case len(got) != 1 || got[0].Severity != tc.want || got[0].Path != "extra.rego" || got[0].Row == 0 || !strings.Contains(got[0].Message, "plugin ssh"): + t.Fatalf("expected one located %s %s naming the plugin, got %+v", tc.want, set, r.PolicyErrors) + case !strings.Contains(got[0].Message, "violation[{...}] if"): + t.Fatalf("the set-form problem must name the fix: %s", got[0].Message) } if len(r.Plugins) != 1 || r.Plugins[0].Name != "ssh" || r.Plugins[0].LibVersion != lib(r.Plugins[0].Source) { t.Fatalf("plugins report = %+v", r.Plugins) @@ -146,40 +135,33 @@ func TestCompat(t *testing.T) { // TestLibProblems: per plugin library and origin, for any bundle (shadowed or not). func TestLibProblems(t *testing.T) { set := []inlinepolicy.Site{{Path: "s.rego", Row: 1, Col: 1}} - ids := []inlinepolicy.Site{{Path: "i.rego", Row: 1, Col: 1}} - shadowed := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "oci"}, Shadowed: true, SetViolations: set, PolicyIDRules: ids} - absolute := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/abs"}, SetViolations: set, PolicyIDRules: ids} + shadowed := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "oci"}, Shadowed: true, SetViolations: set} + absolute := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/abs"}, SetViolations: set} plain := &inlinepolicy.Materialized{Name: "b"} - type want struct{ set, id string } // severity of each code, "" = none for _, tc := range []struct { name string version string m *inlinepolicy.Materialized overlay bool - want want + want string // severity of the set-form problem, "" = none }{ - {"nothing to check, v0.1.9", oldLib, plain, true, want{}}, - {"shadowed, v0.1.9", oldLib, shadowed, true, want{set: agentconfig.SeverityError, id: agentconfig.SeverityWarning}}, - {"not shadowed, v0.1.9", oldLib, absolute, true, want{set: agentconfig.SeverityError, id: agentconfig.SeverityWarning}}, - {"file, v0.1.9", oldLib, absolute, false, want{set: agentconfig.SeverityWarning, id: agentconfig.SeverityWarning}}, - {"v0.7.2", "v0.7.2", absolute, true, want{id: agentconfig.SeverityWarning}}, - {"v0.9.0-rc1", "v0.9.0-rc1", absolute, true, want{id: agentconfig.SeverityWarning}}, - {"unknown", "", absolute, true, want{set: agentconfig.SeverityWarning}}, + {"nothing to check, v0.1.9", oldLib, plain, true, ""}, + {"shadowed, v0.1.9", oldLib, shadowed, true, agentconfig.SeverityError}, + {"not shadowed, v0.1.9", oldLib, absolute, true, agentconfig.SeverityError}, + {"file, v0.1.9", oldLib, absolute, false, agentconfig.SeverityWarning}, + {"v0.7.2", "v0.7.2", absolute, true, ""}, + {"unknown", "", absolute, true, agentconfig.SeverityWarning}, } { t.Run(tc.name, func(t *testing.T) { - var got want + var got string for _, e := range libProblems("ssh", tc.version, []*inlinepolicy.Materialized{tc.m}, tc.overlay) { - switch e.Code { - case agentconfig.PolicyCodePluginLibViolationSetUnsupported: - got.set = e.Severity - case agentconfig.PolicyCodePluginLibPolicyIDUnsupported: - got.id = e.Severity - default: + if e.Code != agentconfig.PolicyCodePluginLibViolationSetUnsupported { t.Fatalf("unexpected problem %+v", e) } + got = e.Severity } if got != tc.want { - t.Fatalf("got %+v, want %+v", got, tc.want) + t.Fatalf("got %q, want %q", got, tc.want) } }) } diff --git a/cmd/identity.go b/cmd/identity.go index 7169120..fc48897 100644 --- a/cmd/identity.go +++ b/cmd/identity.go @@ -15,16 +15,14 @@ import ( // its policy paths separately and records evidence for every policy module of each, so a // policy loaded twice is reported twice: // -// - duplicate-policy-id: two modules declare the same policy_id; // - duplicate-policy-identity: two modules from different policy paths have the same -// evidence identity: the same seed (a policy_id that continues the stream of a module -// loaded too), or, without policy_id, the same package and bundle-relative file (an +// evidence identity: the same seed, or the same package and bundle-relative file (an // inline bundle listed next to the source it extends); // - duplicate-policy-package (R66): the same package from two policy paths otherwise. // -// The first two are errors when the overlay introduces them (it gives the plugin one of the -// policy entries involved, or changes one of the inline bundles involved) and warnings when -// they come from the config file (R34). The last is always a warning. Plugins that use no inline bundle are not +// The first is an error when the overlay introduces it (it gives the plugin one of the +// policy entries involved, or changes one of the inline bundles involved) and a warning when +// it comes from the config file (R34). The last is always a warning. Plugins that use no inline bundle are not // checked: their policy paths are the file's and the vendors' business. // policyOrigin tells overlay-introduced policy problems from file-origin ones (R34). @@ -165,26 +163,14 @@ func identityProblems(pluginName string, modules []loadedModule, severity func(a } for j, b := range modules { for _, a := range modules[:j] { - sameEntry := a.entry == b.entry switch { - case a.id.PolicyID != "" && a.id.PolicyID == b.id.PolicyID: - // Within one source, two vendor modules are the vendor's business, and two - // authored ones are the API's contract check (CheckContract). - if sameEntry && a.authored == b.authored { - continue - } - if !sameEntry { - identityPackages[a.id.Package], identityPackages[b.id.Package] = true, true - } - report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyID, - fmt.Sprintf("policy_id %q is declared by both %s and %s, so their evidence shares one stream; give each policy its own policy_id", a.id.PolicyID, a.where(), b.where())) - case sameEntry: + case a.entry == b.entry: continue case a.id.Package == b.id.Package && sameSeed(a, b): identityPackages[a.id.Package] = true report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyIdentity, fmt.Sprintf("%s and %s write to the same evidence stream (package %s), so each evidence is recorded twice; load only one of them: if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", a.where(), b.where(), a.id.Package)) - case a.id.Package == b.id.Package && a.id.PolicyID == "" && b.id.PolicyID == "" && a.id.Path == b.id.Path: + case a.id.Package == b.id.Package && a.id.Path == b.id.Path: identityPackages[a.id.Package] = true report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyIdentity, fmt.Sprintf("%s is loaded from both %s and %s (package %s), so the plugin records its evidence twice, in two streams; load only one of them: if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", a.id.Path, a.entry, b.entry, a.id.Package)) diff --git a/cmd/inline_test.go b/cmd/inline_test.go index c96f33d..ab9b86c 100644 --- a/cmd/inline_test.go +++ b/cmd/inline_test.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "errors" "fmt" "os" @@ -226,38 +225,6 @@ func TestInline_OverlayDuplicateIdentityRejected_R75(t *testing.T) { } } -// TestInline_PolicyIDIdentities_R75: a policy_id that continues the vendor stream collides -// with the vendor module when both are loaded; a policy_id declared twice across a plugin's -// paths is a duplicate-policy-id. -func TestInline_PolicyIDIdentities_R75(t *testing.T) { - t.Run("continuing id next to the vendor source", func(t *testing.T) { - h, vendor := newInlineHarness(t) - // A policy_id that continues the vendor stream: /. - override := fmt.Sprintf("package compliance_framework.banner\n\nimport rego.v1\n\npolicy_id := %q\n\ntitle := \"banner\"\n\nviolation[{\"id\": \"b\"}] if not input.banner\n", vendor+"/banner.rego") - src, _ := json.Marshal(override) - h.remote.publish(1, `{"plugins":{"ssh":{"policies":["ghcr.io/vendor/policies:v1","inline:ssh"]}},"policy_bundles":{"ssh":{"modules":{"banner.rego":`+string(src)+`}}}}`) - mustStartup(t, h.rc) - r := h.remote.lastReport(t) - errs := rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyIdentity) - if r.Status != agentconfig.StatusRejected || len(errs) != 1 || errs[0].Path != "banner.rego" || !strings.Contains(errs[0].Message, "same evidence stream") { - t.Fatalf("expected a same-stream duplicate-policy-identity error, got %s %+v", r.Status, r.PolicyErrors) - } - }) - t.Run("same policy_id in two paths", func(t *testing.T) { - h, vendor := newInlineHarness(t) - if err := os.WriteFile(filepath.Join(vendor, "motd.rego"), []byte("package compliance_framework.motd\n\nimport rego.v1\n\npolicy_id := \"shared\"\n\ntitle := \"motd\"\n"), 0o644); err != nil { - t.Fatal(err) - } - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\npolicy_id := \"shared\"\n\ntitle := \"extra\"\n"}}}}`) - mustStartup(t, h.rc) - r := h.remote.lastReport(t) - errs := rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyID) - if r.Status != agentconfig.StatusRejected || len(errs) != 1 || errs[0].Path != "extra.rego" || !strings.Contains(errs[0].Message, `"shared"`) { - t.Fatalf("expected a duplicate-policy-id error at the authored module, got %s %+v", r.Status, r.PolicyErrors) - } - }) -} - // TestInline_StablePathSwapsOnlyBetweenRuns_R67: a revision prepared while a run is in // progress does not move the stable path under it; the run loop points it at the new tree // before the next run, and back at the previous tree when it falls back. diff --git a/cmd/shadow.go b/cmd/shadow.go index 8634a61..dc2a5d7 100644 --- a/cmd/shadow.go +++ b/cmd/shadow.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/pluginlib" "github.com/compliance-framework/agent/internal/policyview" "github.com/compliance-framework/api/pkg/agentconfig" ) @@ -215,8 +214,8 @@ func unshadowedWarnings(resolved agentconfig.Config, skip map[string]string, pla listed = append(slices.Clip(listed[:10]), fmt.Sprintf("and %d more", len(forks)-10)) } out = append(out, agentconfig.PolicyError{Bundle: name, Severity: agentconfig.SeverityWarning, Code: inlinepolicy.CodePolicyStreamForked, - Message: fmt.Sprintf("plugin %s receives bundle %s at %s, not at the path of %s (%s), so %d of its modules (%s) record their evidence in new streams instead of the vendor's; a module keeps its stream wherever it is loaded from with an authored policy_id (plugins built on agent ≥ %s)", - pluginName, name, m.Path, m.Extends.Source, why, len(forks), strings.Join(listed, ", "), pluginlib.MinPolicyID)}) + Message: fmt.Sprintf("plugin %s receives bundle %s at %s, not at the path of %s (%s), so %d of its modules (%s) record their evidence in new streams instead of the vendor's", + pluginName, name, m.Path, m.Extends.Source, why, len(forks), strings.Join(listed, ", "))}) } } return out diff --git a/cmd/shadow_test.go b/cmd/shadow_test.go index c855b84..b3997cd 100644 --- a/cmd/shadow_test.go +++ b/cmd/shadow_test.go @@ -23,8 +23,7 @@ const ( shadowSource = "ghcr.io/vendor/policies:v1" // Where the agent extracts the OCI source: relative to its working directory. shadowExtracted = ".compliance-framework/policies/vendor/policies/v1/policies" - // A plugin built on agent v0.1.9 (plugin-local-ssh v0.2.0): no policy_id, no set-form - // violations. + // A plugin built on agent v0.1.9 (plugin-local-ssh v0.2.0): no set-form violations. oldLib = "v0.1.9-0.20250708121809-c5059c3efac8" ) @@ -200,9 +199,6 @@ func TestShadow_PluginReceivesTheVendorPathInItsView(t *testing.T) { if got[pkg].labels["_policy_path"] != shadowExtracted { t.Fatalf("%s: _policy_path = %q", pkg, got[pkg].labels["_policy_path"]) } - if _, ok := got[pkg].labels["_policy_id"]; ok { - t.Fatalf("%s: no _policy_id expected with shadowing", pkg) - } } if got["compliance_framework.keys"].title != "Keys (tuned)" { t.Fatalf("the override must be evaluated, got title %q", got["compliance_framework.keys"].title) @@ -342,7 +338,7 @@ func TestShadow_AbsoluteExtendsIsNotShadowed(t *testing.T) { for i, plugin := range []string{"other", "ssh"} { if e := forked[i]; e.Severity != agentconfig.SeverityWarning || e.Bundle != "ssh" || e.Path != "" || !strings.Contains(e.Message, "plugin "+plugin+" receives bundle ssh") || !strings.Contains(e.Message, vendor+" is absolute") || - !strings.Contains(e.Message, "(banner.rego)") || !strings.Contains(e.Message, "policy_id") { + !strings.Contains(e.Message, "(banner.rego)") { t.Fatalf("warning %d = %+v", i, e) } } @@ -358,15 +354,14 @@ func TestShadow_OldLibOverlay(t *testing.T) { t.Run("object form applies", func(t *testing.T) { h := shadowHarness(t, shadowConfig) withPluginLib(h, oldLib) - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"new.rego":"package compliance_framework.new\n\nimport rego.v1\n\npolicy_id := \"ssh/new\"\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n"}}}}`) + h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"new.rego":"package compliance_framework.new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n"}}}}`) active := mustStartup(t, h.rc) r := h.remote.lastReport(t) if active.overlay == nil || r.Status != agentconfig.StatusApplied { t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) } - ids := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibPolicyIDUnsupported) - if len(ids) != 1 || ids[0].Severity != agentconfig.SeverityWarning || ids[0].Path != "new.rego" { - t.Fatalf("an ignored policy_id is a warning, got %+v", r.PolicyErrors) + if set := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported); len(set) != 0 { + t.Fatalf("the object form works with every plugin, got %+v", set) } }) t.Run("set form is rejected", func(t *testing.T) { diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index d96c4f7..b405fcd 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -73,20 +73,16 @@ vendor-only packages; conflicts that only show on `{}` and input-dependent title left out of the next attempt, so one broken package does not hide the others. A compile error in a vendor file whose package an authored module also defines carries an override hint (R65). The per-plugin results are de-duplicated before they are reported. -### Evidence identity across a plugin's paths (R66, R74, R75) - -`policy-manager` seeds evidence UUIDs from the policy's package, file and plugin path, and, when the module declares -one, from its `policy_id` through `policyeval.SeedPath` (the API's function, so the API, the agent and the UI agree). -Without a `policy_id` the seed is byte for byte the old one: we never change the identity of an existing stream. -The agent checks identity statically, with the same rule the API's contract check enforces (`policy_id := -""`, once per package), over every module of every policy path of each plugin that uses an inline bundle: -the same `policy_id` twice is `duplicate-policy-id`, and the same identity from two paths (equal seeds, or the same -package and bundle-relative file without `policy_id`) is `duplicate-policy-identity`. Both are errors when the overlay -introduces them (it gives the plugin a policy entry the file does not, or changes a bundle involved), warnings -otherwise (R34), so an overlay never fails on the file's own duplicates; what is left of R66 (the same package -with different identities) stays a warning. For an `extends` bundle, an override is compared with the vendor module it -replaces by the seeds plugins would compute at the extends source's path: a changed `package` is -`policy-package-changed`, a different `policy_id` `policy-stream-forked` (warnings). +### Evidence identity across a plugin's paths (R66, R75) + +`policy-manager` seeds evidence UUIDs from the policy's package, file and plugin path; we never change that seed, so +the identity of an existing stream never changes. The agent checks identity statically over every module of every +policy path of each plugin that uses an inline bundle: the same identity from two paths (equal seeds, or the same +package and bundle-relative file) is `duplicate-policy-identity`, an error when the overlay introduces it (it gives +the plugin a policy entry the file does not, or changes a bundle involved) and a warning otherwise (R34), so an +overlay never fails on the file's own duplicates; what is left of R66 (the same package with different identities) +stays a warning. For an `extends` bundle, an override that changes the `package` of the vendor module it replaces is +`policy-package-changed` (a warning). ### Path shadowing (R83, R88) @@ -95,9 +91,8 @@ given to plugins at the source's own path, and the plugin runs with a per-plugin directory (`internal/policyview`), in which that path's parent links to the bundle's tree and everything else mirrors the agent's working directory. Evidence identity is then the vendor's by construction, for every plugin build. The agent resolves every relative policy path of such a plugin through its view (artifact uploads, source props). We -rejected the alternative of appending a continuity `policy_id` to every module that continues a vendor file (R82): it -only worked for plugins rebuilt on agent ≥ v0.9.0 and gave added modules two identities depending on the shadowing -decision. Where a view cannot represent the paths (absolute `extends`, a plugin loading the source and the bundle +rejected declaring the identity in the policy instead (an authored `policy_id` replacing the location in the seed): +it only works for plugins rebuilt on a newer agent library, while shadowing works for every build. Where a view cannot represent the paths (absolute `extends`, a plugin loading the source and the bundle together, no symlinks) the bundle is given to plugins at its own `_inline` path and its modules start path-based streams; each plugin that uses it gets a `policy-stream-forked` warning with the reason. Risk: a plugin that relies on its working directory sees the view (mirrored, so reads and writes inside existing @@ -116,10 +111,9 @@ the agent at startup. View GC removes whole views, plugin-owned entries included What a plugin can do 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 -(`lib-version`). Shadowing makes inline bundles work with every build, so there is no gate: an overlay-introduced -set-form `violation contains` for a plugin older than v0.7.1 is rejected (that `policy-manager` panics on it), and -an authored `policy_id` for a plugin older than `pluginlib.MinPolicyID` (v0.9.0 final; v0.8.0 and v0.8.1 were -released without R74) is a warning. File bundles and unknown versions (a `replace` or devel build, which local +(`lib-version`). Shadowing makes inline bundles work with every build, so there is no gate: only an +overlay-introduced set-form `violation contains` for a plugin older than v0.7.1 is rejected (that `policy-manager` +panics on it). File bundles and unknown versions (a `replace` or devel build, which local development relies on) only warn. Versions compare as semver and a pseudo-version counts as its base tag, so release candidates and untagged commits before the minimum are older. diff --git a/docs/configuration.md b/docs/configuration.md index 159a808..9314d96 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -279,15 +279,13 @@ policy_bundles: also defines, the error carries a hint: keep the rule the override removed, or add the test to `delete`. - **Duplicate evidence (R66, R75).** A plugin evaluates each of its policy paths separately, so a policy it loads twice is recorded twice. Across all of a plugin's policy paths the agent reports: - - `duplicate-policy-id`: two modules declare the same `policy_id`; - - `duplicate-policy-identity`: two paths load the same evidence identity, either the same package and - bundle-relative file without `policy_id` (typically a source listed next to an inline bundle that `extends` it), - or a `policy_id` that continues the stream of a module the plugin also loads; + - `duplicate-policy-identity`: two paths load the same evidence identity, the same package and bundle-relative + file (typically a source listed next to an inline bundle that `extends` it); - `duplicate-policy-package` (warning): the same package from two paths otherwise. - The first two are **errors** when the overlay introduces them (it gives the plugin one of the policy entries - involved, or changes one of the bundles involved) and warnings when they come from the file (R34), even under an - overlay that changes something else. Replace the source with the inline bundle + The first is an **error** when the overlay introduces it (it gives the plugin one of the policy entries involved, + or changes one of the bundles involved) and a warning when it comes from the file (R34), even under an overlay that + changes something else. Replace the source with the inline bundle instead of listing both. - **Path shadowing (R83): inline bundles keep the vendor's evidence streams.** Plugins seed evidence UUIDs from the policy path *string* they receive, so a plugin that keeps receiving the vendor's path keeps the vendor's streams, @@ -310,12 +308,9 @@ policy_bundles: another bundle extending the same source, when another relative policy path of the plugin cannot be represented in a view (e.g. a single-component path such as `policies`), or without symlinks or a writable state directory. Each plugin that uses such a bundle gets a `policy-stream-forked` warning for the bundle that gives the reason and lists - the modules that would have kept the vendor's streams at the source's path. An authored `policy_id` keeps a - module's stream wherever it is loaded from (plugins built on agent ≥ v0.9.0). -- **Overrides and evidence streams (R75).** An override is compared with the vendor module it replaces by the seeds - plugins compute at the source's path. A changed `package` is a `policy-package-changed` warning; a `policy_id` that - differs from the vendor's (an own one, or a dropped vendor one) is a `policy-stream-forked` warning, which names the - vendor's `policy_id` when it has one. + the modules that would have kept the vendor's streams at the source's path. +- **Overrides and evidence streams (R75).** An override keeps the evidence stream of the vendor module it replaces + unless it changes the module's `package`, which is a `policy-package-changed` warning. - **Plugin views (rule 1).** A plugin running in a view sees its working directory as the view: files it creates there (rather than through a mirrored directory) stay in the view and are removed with it. **Plugin contract:** plugins must not rely on creating new files or directories relative to their working @@ -349,59 +344,29 @@ policy_bundles: `artifact-digest` empty and never rejects or fails a revision. Note that artifacts are readable with `artifact:read`. -### Policy identity (`policy_id`, R74) +### Evidence streams Plugins seed each evidence UUID with the policy's package, its file (the plugin path joined with the module's path in -the bundle) and their `_policy_path` label (the plugin path). So moving a policy (a new OCI tag, a renamed bundle, a -bundle that is not shadowed) starts a new evidence stream. A module may declare its identity instead: - -```rego -package compliance_framework.ssh_deny_password_auth - -import rego.v1 - -policy_id := "ssh-deny-password-auth" -``` - -- **Without `policy_id` nothing changes**: the seed is exactly what it has always been. -- **With one**, `policy-manager` seeds with `policyeval.SeedPath`: the `policy_file` seed is the cleaned `policy_id` - (as OPA cleans the policy file), and the `_policy_path` seed (when the plugin labels it) is the literal `policy_id` - minus the module's bundle-relative path when it ends in `/`, else the `policy_id` itself. A `policy_id` that - names the module's own location seeds as if it had none. Evidence keeps its real `_policy_path` label and gains - `_policy_id`. -- **Continue a stream** with `policy_id := "/"`, the literal concatenation of the path plugins - receive for the source, a `/` and the file (not a cleaned join): the old location reproduces the old UUID. This - also works for a local source configured with a non-clean path such as `./policies` or `policies/` - (`"./policies/"`, `"policies//"`). Shadowed bundles need none of this. -- **A stable stream**: any other `policy_id` (for example `/`) does not depend on where the bundle - lives. -- `policy_id` must be `policy_id := ""`, declared once per package, at most 512 characters - (`invalid-policy-id` otherwise), and unique among the policies a plugin loads. +the bundle) and their labels, including `_policy_path` (the plugin path). So moving a policy (a new OCI tag, a renamed +bundle, a bundle that is not shadowed) starts a new evidence stream; a shadowed bundle keeps the vendor's. | Change | Evidence stream | |---|---| -| override a policy in a shadowed bundle, keeping its `package` and `policy_id` | the same stream | +| override a policy in a shadowed bundle, keeping its `package` | the same stream | | `delete` the policy | the stream stops receiving evidence | | revert the override | the same stream | -| a new policy | a new stream, from its `policy_id` | -| publish it into a real bundle with the same `policy_id` | the same stream | +| a new policy | a new stream | | change the overridden module's `package` | a new stream (`policy-package-changed`) | -**`policy_id` needs a rebuilt plugin.** Plugins seed evidence with the `policy-manager` they embed, so an authored -`policy_id` only takes effect for plugins built on an agent library that includes it (agent ≥ v0.9.0, -`pluginlib.MinPolicyID`). Path shadowing needs no rebuild. - ### Plugin compatibility (R76, R88) The agent reads each plugin's agent library version from the binary's Go build info, without starting it, and -reports it as `plugins[]` (`name`, `source`, `lib-version`). Inline bundles work with every plugin build; two -constructs depend on the library: +reports it as `plugins[]` (`name`, `source`, `lib-version`). Inline bundles work with every plugin build; one +construct depends on the library: - **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect `violation[{...}] if { ... }`: an overlay-introduced authored module that uses them for such a plugin is rejected with `plugin-lib-violation-set-unsupported`, with that fix. The running configuration keeps running. -- **An authored `policy_id`** is ignored by plugins older than v0.9.0 (the module's stream follows its path): - `plugin-lib-policy-id-unsupported` warning. - **Unknown versions and file-defined bundles only warn** (R34): a `replace`d or `(devel)` build, a pseudo-version with no tag before it, or a binary without build info. diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 87a8d69..7051b78 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -36,15 +36,6 @@ serves the configuration report, which uploads the policy trees it names (see `configuration.md`, "Sources for the UI"); a tree uploaded there is not uploaded again for evidence, and both produce the same artifact digest. -## Evidence identity - -`GenerateResults` seeds each evidence UUID with the policy's package, its file and the plugin's labels (including -`_policy_path`, the path the agent passed the plugin). When the module declares `policy_id`, `policyeval.SeedPath` -replaces the file and `_policy_path` seed values, so the stream follows the policy rather than where its bundle lives; -the evidence keeps its real `_policy_path` label and gains a `_policy_id` label. Without a `policy_id` the seed is -unchanged. See `configuration.md`, "Policy identity". Plugins get this by rebuilding on an agent library that -includes it. - ## Plugins Plugins need no code changes. A plugin gets this by moving to an agent version that diff --git a/internal/inlinepolicy/identity.go b/internal/inlinepolicy/identity.go index d551bc3..b4d4298 100644 --- a/internal/inlinepolicy/identity.go +++ b/internal/inlinepolicy/identity.go @@ -12,18 +12,14 @@ import ( "github.com/open-policy-agent/opa/v1/ast" ) -// Evidence identity (R74, R75). A plugin seeds each evidence UUID with the policy's package, -// its file (the path the agent passed the plugin joined with the module's path in the -// bundle) and, through its labels, that path; a policy_id replaces the location -// (policyeval.SeedPath). The agent reads policy_id statically, as the API's contract check -// does: only `policy_id := ""` declared once per package counts, which is the only -// form CheckContract accepts. +// Evidence identity (R75). A plugin seeds each evidence UUID with the policy's package, its +// file (the path the agent passed the plugin joined with the module's path in the bundle) +// and, through its labels, that path. // ModuleIdentity is what decides the evidence stream of one policy module. type ModuleIdentity struct { - Path string // slash-separated, relative to the policy root - Package string // without "data." - PolicyID string // the package's policy_id, "" when it declares none (or not validly) + Path string // slash-separated, relative to the policy root + Package string // without "data." } // Site is where an authored construct is. @@ -37,14 +33,13 @@ type Site struct { // them. func Identities(files map[string][]byte) []ModuleIdentity { modules := parseModules(files) - ids := policyeval.StaticPolicyIDs(modules) var out []ModuleIdentity for _, p := range slices.Sorted(maps.Keys(modules)) { pkg := packageOf(modules[p]) if policyeval.IsTestFile(p) || !policyeval.IsPolicyPackage(pkg) { continue } - out = append(out, ModuleIdentity{Path: p, Package: pkg, PolicyID: ids[pkg]}) + out = append(out, ModuleIdentity{Path: p, Package: pkg}) } return out } @@ -58,9 +53,10 @@ func TreeIdentities(dir string) ([]ModuleIdentity, error) { return Identities(files), nil } -// authoredSites returns the authored non-test modules of files that define violation as a -// set (`violation contains ...`, R76) and that declare a policy_id rule. -func authoredSites(files map[string][]byte, authored map[string]bool) (setViolations, policyIDRules []Site) { +// setViolationSites returns the authored non-test modules of files that define violation as +// a set (`violation contains ...`, R76). +func setViolationSites(files map[string][]byte, authored map[string]bool) []Site { + var out []Site for _, p := range slices.Sorted(maps.Keys(files)) { if !authored[p] || !strings.HasSuffix(p, ".rego") || policyeval.IsTestFile(p) { continue @@ -69,8 +65,10 @@ func authoredSites(files map[string][]byte, authored map[string]bool) (setViolat if mod == nil || !policyeval.IsPolicyPackage(packageOf(mod)) { continue } - var setSite, idSite *Site for _, rule := range mod.Rules { + if policyeval.RuleName(rule) != "violation" || rule.Head.Key == nil || rule.Head.Value != nil { + continue + } loc := rule.Head.Location if loc == nil { loc = rule.Location @@ -79,42 +77,26 @@ func authoredSites(files map[string][]byte, authored map[string]bool) (setViolat if loc != nil { site.Row, site.Col = loc.Row, loc.Col } - switch policyeval.RuleName(rule) { - case "violation": - if setSite == nil && rule.Head.Key != nil && rule.Head.Value == nil { - setSite = &site - } - case "policy_id": - if idSite == nil { - idSite = &site - } - } - } - if setSite != nil { - setViolations = append(setViolations, *setSite) - } - if idSite != nil { - policyIDRules = append(policyIDRules, *idSite) + out = append(out, site) + break } } - return setViolations, policyIDRules + return out } // SeedOf returns the evidence seed values (policy_file, _policy_path) of module id when a // plugin loads it from pluginPath, as policy-manager computes them: OPA gives plugins the -// policy file as the path joined with the module's path (cleaned), and policyeval.SeedPath -// applies the policy_id. +// policy file as the path joined with the module's path (cleaned), and plugins label +// _policy_path with pluginPath as passed. func SeedOf(id ModuleIdentity, pluginPath string) (string, string) { - file := filepath.Join(pluginPath, filepath.FromSlash(id.Path)) - return policyeval.SeedPath(id.PolicyID, file, pluginPath) + return filepath.Join(pluginPath, filepath.FromSlash(id.Path)), pluginPath } // OverrideStreams warns about the authored overrides of an extends bundle that do not keep // the evidence stream of the vendor module they replace (R75) even when plugins receive the -// bundle at the extends source's path (shadowed), comparing the seeds plugins compute there: -// a changed package is policy-package-changed, a policy_id that differs from the vendor's -// (an own one, or a dropped vendor one) is policy-stream-forked. What not being shadowed -// costs on top of that is UnshadowedForks. +// bundle at the extends source's path (shadowed): an override at the vendor module's path +// seeds what the vendor module seeds there unless it changes the package +// (policy-package-changed). What not being shadowed costs on top of that is UnshadowedForks. func OverrideStreams(m *Materialized) []agentconfig.PolicyError { if m == nil || m.Extends == nil { return nil @@ -123,32 +105,12 @@ func OverrideStreams(m *Materialized) []agentconfig.PolicyError { var out []agentconfig.PolicyError for _, id := range m.Identities { v, replaced := vendor[id.Path] - if !replaced || !m.Authored[id.Path] { - continue - } - warn := func(code, format string, args ...any) { - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: id.Path, Severity: agentconfig.SeverityWarning, Code: code, Message: fmt.Sprintf(format, args...)}) - } - if id.Package != v.Package { - warn(agentconfig.PolicyCodePolicyPackageChanged, - "the override of %s changes its package from %s to %s, which starts a new evidence stream for it; keep `package %s` to continue the vendor policy's stream", - id.Path, v.Package, id.Package, v.Package) - continue - } - if sameSeed(id, v, m.ExtendsDir, m.ExtendsDir) { + if !replaced || !m.Authored[id.Path] || id.Package == v.Package { continue } - got := "no policy_id" - if id.PolicyID != "" { - got = fmt.Sprintf("policy_id %q", id.PolicyID) - } - hint := "" - if v.PolicyID != "" { - hint = fmt.Sprintf("; declare `policy_id := %q` to continue the vendor policy's stream", v.PolicyID) - } - warn(CodePolicyStreamForked, - "the override of %s has %s, so plugins record its evidence in a new stream, not the stream of %s in %s%s", - id.Path, got, id.Path, m.Extends.Source, hint) + out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: id.Path, Severity: agentconfig.SeverityWarning, Code: agentconfig.PolicyCodePolicyPackageChanged, + Message: fmt.Sprintf("the override of %s changes its package from %s to %s, which starts a new evidence stream for it; keep `package %s` to continue the vendor policy's stream", + id.Path, v.Package, id.Package, v.Package)}) } return out } @@ -156,8 +118,7 @@ func OverrideStreams(m *Materialized) []agentconfig.PolicyError { // UnshadowedForks returns the paths of the modules of m that keep the evidence stream of the // vendor module at their path when plugins receive m at the extends source's path, but not // at m.Path, where they do when m is not shadowed: inherited modules and overrides that keep -// the vendor's package and policy_id, unless that policy_id makes the stream -// location-independent. Nil when m is shadowed or extends nothing. +// the vendor's package. Nil when m is shadowed or extends nothing. func UnshadowedForks(m *Materialized) []string { if m == nil || m.Extends == nil || m.Shadowed { return nil @@ -166,7 +127,7 @@ func UnshadowedForks(m *Materialized) []string { var out []string for _, id := range m.Identities { v, ok := vendor[id.Path] - if ok && id.Package == v.Package && sameSeed(id, v, m.ExtendsDir, m.ExtendsDir) && !sameSeed(id, v, m.Path, m.ExtendsDir) { + if ok && id.Package == v.Package && !sameSeed(id, v, m.Path, m.ExtendsDir) { out = append(out, id.Path) } } @@ -189,9 +150,9 @@ func sameSeed(id, v ModuleIdentity, path, vendorPath string) bool { return file == vendorFile && policyPath == vendorPolicyPath } -// CodePolicyStreamForked is the PolicyError code of modules of an extends bundle that do not -// continue the evidence stream of the vendor module at their path (R75, R88): an override -// with another policy_id, or the modules of a bundle that is not shadowed. Warning. +// CodePolicyStreamForked is the PolicyError code of the modules of an extends bundle that is +// not shadowed, which do not continue the evidence stream of the vendor module at their path +// (R88). Warning. const CodePolicyStreamForked = "policy-stream-forked" // parseRego parses a module as Rego v1, then as Rego v0, as plugins on either OPA major diff --git a/internal/inlinepolicy/identity_test.go b/internal/inlinepolicy/identity_test.go index 9d56dff..c178616 100644 --- a/internal/inlinepolicy/identity_test.go +++ b/internal/inlinepolicy/identity_test.go @@ -2,8 +2,6 @@ package inlinepolicy import ( "context" - "os" - "path/filepath" "testing" "github.com/compliance-framework/api/pkg/agentconfig" @@ -13,66 +11,44 @@ import ( func TestIdentities(t *testing.T) { ids := Identities(map[string][]byte{ - "a.rego": []byte("package compliance_framework.a\n\npolicy_id := \"a-id\"\n\ntitle := \"a\"\n"), - "a_extra.rego": []byte("package compliance_framework.a\n\ntitle2 := \"x\"\n"), - "a_test.rego": []byte("package compliance_framework.a\n\npolicy_id := \"ignored\"\n"), - "b/b.rego": []byte("package compliance_framework.b\n\ntitle := \"b\"\n"), - "twice.rego": []byte("package compliance_framework.twice\n\npolicy_id := \"one\"\n"), - "twice2.rego": []byte("package compliance_framework.twice\n\npolicy_id := \"two\"\n"), - "cond.rego": []byte("package compliance_framework.cond\n\npolicy_id := \"c\" if input.x\n"), - "computed.rego": []byte("package compliance_framework.computed\n\npolicy_id := concat(\"-\", [\"a\", \"b\"])\n"), - "lib.rego": []byte("package ccf_libs.helpers\n\npolicy_id := \"lib\"\n"), - // Only `policy_id := ""` rules define it (policyeval.StaticPolicyIDs): a - // function or a set is a contract error, not a second definition. - "fn.rego": []byte("package compliance_framework.fn\n\npolicy_id := \"fn-id\"\n\npolicy_id(x) := x\n"), - "set.rego": []byte("package compliance_framework.set\n\npolicy_id := \"set-id\"\n\npolicy_id contains \"y\"\n"), - "broken.rego": []byte("package compliance_framework.broken\n\ntitle := \n"), - "data.json": []byte("{}"), + "a.rego": []byte("package compliance_framework.a\n\ntitle := \"a\"\n"), + "a_extra.rego": []byte("package compliance_framework.a\n\ntitle2 := \"x\"\n"), + "a_test.rego": []byte("package compliance_framework.a\n\ntest_ok := true\n"), + "b/b.rego": []byte("package compliance_framework.b\n\ntitle := \"b\"\n"), + "lib.rego": []byte("package ccf_libs.helpers\n\nyes := true\n"), + "broken.rego": []byte("package compliance_framework.broken\n\ntitle := \n"), + "data.json": []byte("{}"), }) assert.Equal(t, []ModuleIdentity{ - {Path: "a.rego", Package: "compliance_framework.a", PolicyID: "a-id"}, - {Path: "a_extra.rego", Package: "compliance_framework.a", PolicyID: "a-id"}, // the package's policy_id + {Path: "a.rego", Package: "compliance_framework.a"}, + {Path: "a_extra.rego", Package: "compliance_framework.a"}, {Path: "b/b.rego", Package: "compliance_framework.b"}, - {Path: "computed.rego", Package: "compliance_framework.computed"}, // only literals count - {Path: "cond.rego", Package: "compliance_framework.cond"}, - {Path: "fn.rego", Package: "compliance_framework.fn", PolicyID: "fn-id"}, - {Path: "set.rego", Package: "compliance_framework.set", PolicyID: "set-id"}, - {Path: "twice.rego", Package: "compliance_framework.twice"}, // declared twice: none - {Path: "twice2.rego", Package: "compliance_framework.twice"}, }, ids) } -func TestAuthoredSites(t *testing.T) { +func TestSetViolationSites(t *testing.T) { files := map[string][]byte{ - "set.rego": []byte("package compliance_framework.set\n\nimport rego.v1\n\npolicy_id := \"s\"\n\nviolation contains {\"id\": \"x\"} if input.bad\n"), + "set.rego": []byte("package compliance_framework.set\n\nimport rego.v1\n\nviolation contains {\"id\": \"x\"} if input.bad\n"), "object.rego": []byte("package compliance_framework.object\n\nimport rego.v1\n\nviolation[{\"id\": \"x\"}] if input.bad\n"), "vendor.rego": []byte("package compliance_framework.vendor\n\nimport rego.v1\n\nviolation contains {\"id\": \"x\"} if input.bad\n"), "set_test.rego": []byte("package compliance_framework.set\n\nimport rego.v1\n\nviolation contains 1 if true\n"), } - set, ids := authoredSites(files, map[string]bool{"set.rego": true, "object.rego": true, "set_test.rego": true}) - assert.Equal(t, []Site{{Path: "set.rego", Row: 7, Col: 1}}, set, "only authored set-form violations; the object form works with every plugin") - assert.Equal(t, []Site{{Path: "set.rego", Row: 5, Col: 1}}, ids) + set := setViolationSites(files, map[string]bool{"set.rego": true, "object.rego": true, "set_test.rego": true}) + assert.Equal(t, []Site{{Path: "set.rego", Row: 5, Col: 1}}, set, "only authored set-form violations; the object form works with every plugin") } // TestOverrideStreams_R75: an override continues the vendor module's stream at the vendor's -// path only with the vendor's package and the vendor's policy_id (or one that names the -// vendor's policy file). Served at the bundle's own path (not shadowed), the modules that do -// are UnshadowedForks. +// path unless it changes the package. Served at the bundle's own path (not shadowed), the +// modules that keep the vendor's package are UnshadowedForks. func TestOverrideStreams_R75(t *testing.T) { - const vendorID = "package compliance_framework.ided\n\nimport rego.v1\n\npolicy_id := \"vendor-id\"\n\ntitle := \"x\"\n" - dir, resolve := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth, "ided.rego": vendorID}) - continuing := "package compliance_framework.banner\n\nimport rego.v1\n\npolicy_id := \"" + dir + "/banner.rego\"\n\ntitle := \"Banner\"\n" + _, resolve := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth}) cases := []struct { name string modules map[string]string want map[string]string // path -> code forks []string }{ - {"continues the legacy stream", map[string]string{"banner.rego": continuing}, map[string]string{}, []string{"max_auth.rego"}}, - {"keeps the vendor policy_id", map[string]string{"ided.rego": vendorID + "\n# changed\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, - {"no policy_id", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, - {"changed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\npolicy_id := \"other\"\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}, []string{"banner.rego", "max_auth.rego"}}, - {"removed policy_id", map[string]string{"ided.rego": "package compliance_framework.ided\n\ntitle := \"x\"\n"}, map[string]string{"ided.rego": CodePolicyStreamForked}, []string{"banner.rego", "max_auth.rego"}}, + {"changed override", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, {"changed package", map[string]string{"max_auth.rego": "package compliance_framework.max_auth_v2\n\ntitle := \"x\"\n"}, map[string]string{"max_auth.rego": agentconfig.PolicyCodePolicyPackageChanged}, []string{"banner.rego"}}, {"new module", map[string]string{"new.rego": "package compliance_framework.new\n\ntitle := \"x\"\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, } @@ -95,29 +71,3 @@ func TestOverrideStreams_R75(t *testing.T) { assert.Empty(t, OverrideStreams(m), "a bundle without extends overrides nothing") assert.Empty(t, UnshadowedForks(m)) } - -// TestUnshadowedForks_NonCleanLocalSource: for a local source configured with a non-clean -// path, an authored policy_id that is the literal "/" keeps the stream at -// the bundle's own path; the cleaned join does not (plugins label _policy_path with the -// literal path). -func TestUnshadowedForks_NonCleanLocalSource(t *testing.T) { - for _, pluginPath := range []string{"./policies", "./policies/", "policies/"} { - t.Run(pluginPath, func(t *testing.T) { - t.Chdir(t.TempDir()) - require.NoError(t, os.MkdirAll("policies", 0o755)) - require.NoError(t, os.WriteFile(filepath.Join("policies", "banner.rego"), []byte(vendorBanner), 0o644)) - resolve := func(_ context.Context, source string) (string, error) { return pluginPath, nil } - - override := func(id string) *Materialized { - t.Helper() - src := "package compliance_framework.banner\n\npolicy_id := \"" + id + "\"\n\ntitle := \"Banner\"\n" - m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("local"), Modules: map[string]string{"banner.rego": src}}, resolve) - require.NoError(t, err) - assert.Empty(t, OverrideStreams(m), "both continue the stream at the vendor's path") - return m - } - assert.Empty(t, UnshadowedForks(override(pluginPath+"/banner.rego")), "the literal path continues the stream anywhere") - assert.Equal(t, []string{"banner.rego"}, UnshadowedForks(override("policies/banner.rego")), "the cleaned path seeds a different _policy_path") - }) - } -} diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go index 34ba3a9..3e41f7d 100644 --- a/internal/inlinepolicy/materialize.go +++ b/internal/inlinepolicy/materialize.go @@ -94,9 +94,8 @@ type Materialized struct { // ExtendsIdentities those of the extends tree (R75). Identities, ExtendsIdentities []ModuleIdentity // SetViolations are the authored modules that define violation as a set, which plugins - // built on an agent library older than v0.7.1 cannot evaluate; PolicyIDRules are the - // authored modules that declare policy_id, which plugins built before R74 ignore (R76). - SetViolations, PolicyIDRules []Site + // built on an agent library older than v0.7.1 cannot evaluate (R76). + SetViolations []Site // Shadowed is set when plugins receive the bundle at the extends source's own path // (ExtendsDir), resolved to Dir inside each plugin's view (internal/policyview). Shadowed bool @@ -226,7 +225,7 @@ func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.Poli return nil, errs } - m.SetViolations, m.PolicyIDRules = authoredSites(files, m.Authored) + m.SetViolations = setViolationSites(files, m.Authored) m.Shadowed = opt.Shadow && m.Extends != nil && policyview.Shadowable(m.ExtendsDir) == nil // 6. Write once. diff --git a/internal/inlinepolicy/streams_test.go b/internal/inlinepolicy/streams_test.go index 2ffff04..215d183 100644 --- a/internal/inlinepolicy/streams_test.go +++ b/internal/inlinepolicy/streams_test.go @@ -31,7 +31,7 @@ const ( var streamsVendorFiles = map[string]string{ "ssh/require_key_based_ssh.rego": "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH\"\n\nviolation contains {\"id\": \"k\"} if input.password\n", - "ssh/deny_root_login.rego": "package compliance_framework.deny_root_login\n\nimport rego.v1\n\npolicy_id := \"ssh-deny-root-login\"\n\ntitle := \"No root login\"\n\nviolation contains {\"id\": \"r\"} if input.root\n", + "ssh/deny_root_login.rego": "package compliance_framework.deny_root_login\n\nimport rego.v1\n\ntitle := \"No root login\"\n\nviolation contains {\"id\": \"r\"} if input.root\n", "banner.rego": "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"b\"} if not input.banner\n", "ssh/require_key_based_ssh_test.rego": "package compliance_framework.require_key_based_ssh_test\n\nimport rego.v1\n\ntest_ok if true\n", "lib/helpers.rego": "package ccf_libs.helpers\n\nimport rego.v1\n\nyes := true\n", @@ -108,10 +108,9 @@ func shadowView(t *testing.T, m *Materialized) string { } // TestStreams_UnshadowedBundleStartsPathStreams: a bundle plugins receive at its own path -// keeps the vendor files as they are (no policy_id is added), so its inherited modules start -// path-based streams under the relative _inline path (UnshadowedForks); a module whose -// vendor package declares a policy_id keeps the vendor stream. Another revision keeps the -// path and the streams (R67). +// keeps the vendor files as they are, so its inherited modules start path-based streams +// under the relative _inline path (UnshadowedForks). Another revision keeps the path and the +// streams (R67). func TestStreams_UnshadowedBundleStartsPathStreams(t *testing.T) { l, resolve := streamsSetup(t) m := streamsMaterialize(t, l, resolve, map[string]string{ @@ -128,17 +127,14 @@ func TestStreams_UnshadowedBundleStartsPathStreams(t *testing.T) { got := streamsRun(t, wd, m.Path) assert.Len(t, got, 4, "the three vendor policies and the new one") vendor := streamsRun(t, wd, streamsVendor) - for _, pkg := range []string{"compliance_framework.require_key_based_ssh", "compliance_framework.banner"} { + for _, pkg := range []string{"compliance_framework.require_key_based_ssh", "compliance_framework.banner", "compliance_framework.deny_root_login"} { require.Contains(t, got, pkg) assert.NotEqual(t, vendor[pkg].uuid, got[pkg].uuid, "%s starts a path-based stream", pkg) assert.Equal(t, m.Path, got[pkg].labels["_policy_path"]) - assert.NotContains(t, got[pkg].labels, "_policy_id") } - assert.Equal(t, vendor["compliance_framework.deny_root_login"].uuid, got["compliance_framework.deny_root_login"].uuid, - "the vendor's own policy_id keeps the stream") assert.Empty(t, OverrideStreams(m), "no override") - assert.Equal(t, []string{"banner.rego", "ssh/require_key_based_ssh.rego"}, UnshadowedForks(m)) + assert.Equal(t, []string{"banner.rego", "ssh/deny_root_login.rego", "ssh/require_key_based_ssh.rego"}, UnshadowedForks(m)) // The report inventories the tree as written; the vendor files stay in the extends report. assert.Equal(t, sha(streamsVendorFiles["banner.rego"]), fileSHA(m.Extends.Files, "banner.rego")) @@ -209,7 +205,6 @@ func TestStreams_Overrides(t *testing.T) { shadow bool modules map[string]string want map[string]string // path -> code - hint string // a policy_id the warning suggests forks []string // UnshadowedForks }{ { @@ -224,35 +219,16 @@ func TestStreams_Overrides(t *testing.T) { modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh_v2\n\nimport rego.v1\n\ntitle := \"v2\"\n"}, want: map[string]string{keyBased: agentconfig.PolicyCodePolicyPackageChanged}, }, - { - name: "shadowed, own policy_id", - shadow: true, - modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\npolicy_id := \"ssh-key-based\"\n\ntitle := \"tuned\"\n"}, - want: map[string]string{keyBased: CodePolicyStreamForked}, - }, - { - name: "shadowed, vendor policy_id dropped", - shadow: true, - modules: map[string]string{rootLogin: "package compliance_framework.deny_root_login\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, - want: map[string]string{rootLogin: CodePolicyStreamForked}, - hint: `policy_id := "ssh-deny-root-login"`, - }, { name: "not shadowed, same package", modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, want: map[string]string{}, - forks: []string{"banner.rego", keyBased}, + forks: []string{"banner.rego", rootLogin, keyBased}, }, { - name: "not shadowed, own policy_id", - modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\npolicy_id := \"ssh-key-based\"\n\ntitle := \"tuned\"\n"}, - want: map[string]string{keyBased: CodePolicyStreamForked}, - forks: []string{"banner.rego"}, - }, - { - name: "not shadowed, vendor policy_id kept", - modules: map[string]string{rootLogin: "package compliance_framework.deny_root_login\n\nimport rego.v1\n\npolicy_id := \"ssh-deny-root-login\"\n\ntitle := \"tuned\"\n"}, - want: map[string]string{}, + name: "not shadowed, changed package", + modules: map[string]string{rootLogin: "package compliance_framework.deny_root_login_v2\n\nimport rego.v1\n\ntitle := \"v2\"\n"}, + want: map[string]string{rootLogin: agentconfig.PolicyCodePolicyPackageChanged}, forks: []string{"banner.rego", keyBased}, }, } { @@ -265,9 +241,6 @@ func TestStreams_Overrides(t *testing.T) { assert.Equal(t, tc.forks, UnshadowedForks(m)) for _, w := range warnings { assert.Equal(t, agentconfig.SeverityWarning, w.Severity) - if tc.hint != "" && w.Path != "" { - assert.Contains(t, w.Message, tc.hint) - } } }) } diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go index 0876a8d..871352d 100644 --- a/internal/pluginlib/pluginlib.go +++ b/internal/pluginlib/pluginlib.go @@ -26,14 +26,6 @@ const ( // set (`violation contains {...}`, agent#86). Older plugins expect an object // (`violation[{...}] if { ... }`) and crash on a set. MinViolationSet = "v0.7.1" - // MinPolicyID is the first agent library release whose policy-manager seeds evidence - // with an authored policy_id (R74): agent#95 ships in v0.9.0 (v0.8.0 and v0.8.1 were - // released without it). Older plugins ignore policy_id. The minimum is the final - // release, so v0.9.0 release candidates and pseudo-versions based on them are older - // (AtLeast). It also covers MinViolationSet. - // - // Update before tagging if agent#95 ships in a different release. - MinPolicyID = "v0.9.0" ) // Version returns the version of AgentModule the plugin binary at path was built with, or "" @@ -62,7 +54,7 @@ func Version(path string) (string, error) { // before it): then ok is false too. A pseudo-version counts as the tagged version it was // built after (v0.7.2-0.2026…-abc is v0.7.1 plus unreleased commits, which may not include // what min added). Versions compare as semver, so pre-releases of min are older than min -// (v0.9.0-rc1 < v0.9.0): a release candidate cut before a feature landed does not have it. +// (v0.7.1-rc1 < v0.7.1): a release candidate cut before a feature landed does not have it. func AtLeast(version, min string) (ok, known bool) { base := version if module.IsPseudoVersion(version) { diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go index 2d4b320..efda579 100644 --- a/internal/pluginlib/pluginlib_test.go +++ b/internal/pluginlib/pluginlib_test.go @@ -18,36 +18,23 @@ func TestAtLeast(t *testing.T) { {"v0.7.2", MinViolationSet, true, true}, {"v0.7.0", MinViolationSet, false, true}, {"v0.1.9", MinViolationSet, false, true}, - {"v0.1.9-0.20250101000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.1.8 - {"v0.7.2-0.20260601000000-abcdefabcdef", MinViolationSet, true, true}, // after v0.7.1 - {"v0.7.1-0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.0, before v0.7.1 - {"v0.7.1-rc1", MinViolationSet, false, true}, // semver: before v0.7.1 - {"v0.8.0-rc1", MinPolicyID, false, true}, // predate R74 - {"v0.8.0-rc4", MinPolicyID, false, true}, - {"v0.8.0-rc4.0.20261001000000-abcdefabcdef", MinPolicyID, false, true}, // after v0.8.0-rc4 - {"v0.7.2", MinPolicyID, false, true}, - {"v0.8.0", MinPolicyID, false, true}, // released without R74 (R81) - {"v0.8.1", MinPolicyID, false, true}, // released without R74 (R81) - {"v0.8.2-0.20261001000000-abcdefabcdef", MinPolicyID, false, true}, // after v0.8.1 - {"v0.9.0-rc1", MinPolicyID, false, true}, // semver: before v0.9.0 - {"v0.9.0-rc1.0.20261001000000-abcdefabcdef", MinPolicyID, false, true}, // after v0.9.0-rc1 - {"v0.9.0", MinPolicyID, true, true}, - {"v0.9.1-0.20261001000000-abcdefabcdef", MinPolicyID, true, true}, // after v0.9.0 - {"v0.9.1", MinPolicyID, true, true}, - {"v0.10.0", MinPolicyID, true, true}, - {"v1.0.0", MinPolicyID, true, true}, - {"", MinPolicyID, false, false}, - {"(devel)", MinPolicyID, false, false}, - {"v0.0.0-20261001000000-abcdefabcdef", MinPolicyID, false, false}, // no tag before it - {"garbage", MinPolicyID, false, false}, + {"v0.1.9-0.20250101000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.1.8 + {"v0.7.2-0.20260601000000-abcdefabcdef", MinViolationSet, true, true}, // after v0.7.1 + {"v0.7.1-0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.0, before v0.7.1 + {"v0.7.1-rc1", MinViolationSet, false, true}, // semver: before v0.7.1 + {"v0.7.1-rc1.0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.1-rc1 + {"v0.10.0", MinViolationSet, true, true}, + {"v1.0.0", MinViolationSet, true, true}, + {"", MinViolationSet, false, false}, + {"(devel)", MinViolationSet, false, false}, + {"v0.0.0-20261001000000-abcdefabcdef", MinViolationSet, false, false}, // no tag before it + {"garbage", MinViolationSet, false, false}, } for _, tc := range cases { ok, known := AtLeast(tc.version, tc.min) assert.Equal(t, tc.ok, ok, "%s >= %s", tc.version, tc.min) assert.Equal(t, tc.known, known, "%s known", tc.version) } - ok, known := AtLeast(MinPolicyID, MinViolationSet) - assert.True(t, ok && known, "MinPolicyID must cover MinViolationSet") } func TestVersion(t *testing.T) { diff --git a/policy-manager/evidence_seed_test.go b/policy-manager/evidence_seed_test.go new file mode 100644 index 0000000..7b65ea9 --- /dev/null +++ b/policy-manager/evidence_seed_test.go @@ -0,0 +1,46 @@ +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 (path shadowing +// keeps vendor streams only because the seed is the path string): 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" + inlinePath = "/app/.compliance-framework/state/local-dev/inline/test/current/bundle" + ) + 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(inlinePath), inlinePath + "/ssh/banner.rego", "data.compliance_framework.banner", "966b3869-b39b-4b2b-9257-7f475e3bb54c"}, + {"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/policy-manager/policy-manager.go b/policy-manager/policy-manager.go index 4551c2f..3507d50 100644 --- a/policy-manager/policy-manager.go +++ b/policy-manager/policy-manager.go @@ -148,7 +148,7 @@ func (p *PolicyProcessor) GenerateResults(ctx context.Context, policyPath string // Observation UUID should differ for each individual subject, but remain consistent when validating the same policy for the same subject. // This acts as an identifier to show the history of an observation. - evidence, err := p.newEvidence(result, policyPath, activities) + evidence, err := p.newEvidence(result, activities) if err != nil { resultErr = errors.Join(resultErr, err) continue @@ -221,29 +221,16 @@ func validateNewEvidence(result Result) error { return nil } -// Evidence labels that name the policy an evidence came from. -const ( - labelPolicy = "_policy" - labelPolicyPath = "_policy_path" - // labelPolicyID is the policy's policy_id, when it declares one (R74). - labelPolicyID = "_policy_id" -) - -// newEvidence builds the evidence for one policy result. policyPath is the path the agent -// passed the plugin for the result's bundle. -// -// The evidence UUID is seeded with the policy's package and file and the plugin's labels, so -// the same policy and subject always produce the same UUID: that is the evidence stream. -// When the policy declares a policy_id, policyeval.SeedPath replaces the file and, if the -// plugin labels carry one, the _policy_path seed value, so the stream follows the policy -// rather than where its bundle lives. Without a policy_id the seed is exactly what it has -// always been, so existing streams keep their UUIDs. -func (p *PolicyProcessor) newEvidence(result Result, policyPath string, activities []*proto.Activity) (*proto.Evidence, error) { +func (p *PolicyProcessor) newEvidence(result Result, activities []*proto.Activity) (*proto.Evidence, error) { if err := validateNewEvidence(result); err != nil { return nil, err } - evidenceUUID, err := sdk.SeededUUID(p.evidenceSeed(result, policyPath)) + evidenceUUID, err := sdk.SeededUUID(MergeMaps(map[string]string{ + "type": "evidence", + "policy": result.Policy.Package.PurePackage(), + "policy_file": result.Policy.File, + }, p.labels)) if err != nil { return nil, err } @@ -252,20 +239,15 @@ func (p *PolicyProcessor) newEvidence(result Result, policyPath string, activiti if result.Labels != nil { resultLabels = *result.Labels } - labels := MergeMaps( - map[string]string{ - labelPolicy: result.Policy.Package.PurePackage(), - }, - p.labels, - resultLabels, - ) - if result.Policy.ID != "" { - // Set last: it records the identity the UUID was seeded with. - labels[labelPolicyID] = result.Policy.ID - } evidence := proto.Evidence{ - UUID: evidenceUUID.String(), - Labels: labels, + UUID: evidenceUUID.String(), + Labels: MergeMaps( + map[string]string{ + "_policy": result.Policy.Package.PurePackage(), + }, + p.labels, + resultLabels, + ), Start: timestamppb.New(time.Now()), End: timestamppb.New(time.Now()), Origins: []*proto.Origin{{Actors: p.actors}}, @@ -278,25 +260,6 @@ func (p *PolicyProcessor) newEvidence(result Result, policyPath string, activiti return &evidence, nil } -// evidenceSeed returns the values an evidence UUID is seeded with (see newEvidence). Only -// the seed changes for a policy_id: the evidence keeps the plugin's real _policy_path label. -func (p *PolicyProcessor) evidenceSeed(result Result, policyPath string) map[string]string { - policyFile := result.Policy.File - labels := p.labels - if result.Policy.ID != "" { - seedFile, seedPolicyPath := policyeval.SeedPath(result.Policy.ID, result.Policy.File, policyPath) - policyFile = seedFile - if _, ok := p.labels[labelPolicyPath]; ok { - labels = MergeMaps(p.labels, map[string]string{labelPolicyPath: seedPolicyPath}) - } - } - return MergeMaps(map[string]string{ - "type": "evidence", - "policy": result.Policy.Package.PurePackage(), - "policy_file": policyFile, - }, labels) -} - func (pm *PolicyManager) GetRiskTemplates(ctx context.Context) (map[string][]*proto.RiskTemplate, error) { query, err := pm.prepareForEval(ctx, rego.Query("data.compliance_framework"), diff --git a/policy-manager/policy-manager_test.go b/policy-manager/policy-manager_test.go index ce8ad89..b9152f4 100644 --- a/policy-manager/policy-manager_test.go +++ b/policy-manager/policy-manager_test.go @@ -360,7 +360,7 @@ func TestPolicyProcessorNewEvidenceRejectsMissingTitle(t *testing.T) { Package: Package("data.compliance_framework.missing_title"), }, EvalOutput: &EvalOutput{}, - }, "", nil) + }, nil) assert.Nil(t, evidence) assert.EqualError(t, err, "evidence title is required") diff --git a/policy-manager/policy_id_test.go b/policy-manager/policy_id_test.go deleted file mode 100644 index f75d725..0000000 --- a/policy-manager/policy_id_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package policy_manager - -import ( - "context" - "os" - "path" - "path/filepath" - "testing" - - "github.com/compliance-framework/agent/runner/proto" - "github.com/hashicorp/go-hclog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Policy identity (R74): a policy_id replaces the location in the evidence UUID seed, and -// without one the seed is exactly what plugins have always used. - -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" - // inlinePath is the stable path an inline bundle is passed as (R67): absolute. - inlinePath = "/app/.compliance-framework/state/local-dev/inline/test/current/bundle" -) - -func evidenceUUID(t *testing.T, labels map[string]string, policyPath, file, pkg, id string) *proto.Evidence { - t.Helper() - p := &PolicyProcessor{labels: labels} - e, err := p.newEvidence(Result{ - Policy: Policy{File: file, Package: Package(pkg), ID: id}, - EvalOutput: &EvalOutput{Title: Pointer("t")}, - }, policyPath, nil) - require.NoError(t, err) - return e -} - -func sshLabels(policyPath string) map[string]string { - return map[string]string{"type": "ssh", "hostname": "web-1", "_policy_path": policyPath} -} - -// TestEvidenceSeedWithoutPolicyIDIsUnchanged pins the UUIDs plugins produced before R74 for -// several label and path combinations: without a policy_id they must never change. -func TestEvidenceSeedWithoutPolicyIDIsUnchanged(t *testing.T) { - cases := []struct { - name string - labels map[string]string - policyPath string - file, pkg string - want string - }{ - {"relative OCI path", sshLabels(vendorPath), vendorPath, vendorPath + "/ssh_deny_password_auth.rego", "data.compliance_framework.ssh_deny_password_auth", "cede5222-a458-4465-8134-c3751575cdd9"}, - {"absolute inline path, nested file", sshLabels(inlinePath), inlinePath, inlinePath + "/ssh/banner.rego", "data.compliance_framework.banner", "966b3869-b39b-4b2b-9257-7f475e3bb54c"}, - {"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", "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/", "policies/a.rego", "data.compliance_framework.a", "98fc06e7-b1a4-4d83-a272-df4fdd75d06c"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - e := evidenceUUID(t, tc.labels, tc.policyPath, tc.file, tc.pkg, "") - assert.Equal(t, tc.want, e.UUID) - assert.NotContains(t, e.Labels, labelPolicyID) - }) - } -} - -// TestPolicyIDContinuesTheLegacyStream: an override that declares the replaced policy's -// legacy policy_file as its policy_id writes to the replaced policy's stream, wherever the -// override lives. -func TestPolicyIDContinuesTheLegacyStream(t *testing.T) { - const pkg = "data.compliance_framework.ssh_deny_password_auth" - cases := []struct { - name string - legacyPath, file string - overridePath string - overridePathLabels string - }{ - {"vendor OCI bundle overridden inline", vendorPath, "ssh_deny_password_auth.rego", inlinePath, inlinePath}, - {"nested file", vendorPath, "ssh/ssh_deny_password_auth.rego", inlinePath, inlinePath}, - {"inline bundle renamed", inlinePath, "ssh_deny_password_auth.rego", "/app/.compliance-framework/state/local-dev/inline/renamed/current/bundle", "/app/.compliance-framework/state/local-dev/inline/renamed/current/bundle"}, - {"OCI tag bumped", vendorPath, "ssh_deny_password_auth.rego", ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.3.0/policies", ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.3.0/policies"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - legacyFile := tc.legacyPath + "/" + tc.file - legacy := evidenceUUID(t, sshLabels(tc.legacyPath), tc.legacyPath, legacyFile, pkg, "") - override := evidenceUUID(t, sshLabels(tc.overridePathLabels), tc.overridePath, tc.overridePath+"/"+tc.file, pkg, legacyFile) - assert.Equal(t, legacy.UUID, override.UUID, "the override continues the legacy stream") - - assert.Equal(t, tc.overridePathLabels, override.Labels["_policy_path"], "the evidence keeps the real _policy_path") - assert.Equal(t, legacyFile, override.Labels[labelPolicyID]) - }) - } -} - -// TestOpaquePolicyIDIsLocationIndependent: an opaque policy_id gives the same stream -// whatever the bundle is called or where it lives, and a different stream from the -// path-based one. -func TestOpaquePolicyIDIsLocationIndependent(t *testing.T) { - const pkg = "data.compliance_framework.ssh_deny_password_auth" - const id = "ssh-deny-password-auth" - paths := []string{ - inlinePath, - "/app/.compliance-framework/state/local-dev/inline/renamed/current/bundle", - "/var/lib/ccf/state/inline/test/current/bundle", - vendorPath, - } - var uuids []string - for _, path := range paths { - e := evidenceUUID(t, sshLabels(path), path, path+"/ssh_deny_password_auth.rego", pkg, id) - uuids = append(uuids, e.UUID) - assert.Equal(t, id, e.Labels[labelPolicyID]) - assert.Equal(t, path, e.Labels["_policy_path"]) - } - for _, u := range uuids[1:] { - assert.Equal(t, uuids[0], u) - } - pathBased := evidenceUUID(t, sshLabels(inlinePath), inlinePath, inlinePath+"/ssh_deny_password_auth.rego", pkg, "") - assert.NotEqual(t, pathBased.UUID, uuids[0]) - - // The package stays in the seed: a different package is a different stream. - other := evidenceUUID(t, sshLabels(inlinePath), inlinePath, inlinePath+"/ssh_deny_password_auth.rego", "data.compliance_framework.other", id) - assert.NotEqual(t, uuids[0], other.UUID) -} - -// TestPolicyIDWithoutPolicyPathLabel: plugins that do not label _policy_path seed only the -// policy_file with the policy_id; no _policy_path key is added to their seed. -func TestPolicyIDWithoutPolicyPathLabel(t *testing.T) { - labels := map[string]string{"_plugin": "test-plugin"} - p := &PolicyProcessor{labels: labels} - seed := p.evidenceSeed(Result{Policy: Policy{File: "/b/x.rego", Package: "data.compliance_framework.x", ID: "x"}}, "/b") - assert.Equal(t, map[string]string{"type": "evidence", "policy": "compliance_framework.x", "policy_file": "x", "_plugin": "test-plugin"}, seed) - assert.Equal(t, map[string]string{"_plugin": "test-plugin"}, labels, "the plugin's labels are not modified") - - labels = sshLabels("/b") - p = &PolicyProcessor{labels: labels} - seed = p.evidenceSeed(Result{Policy: Policy{File: "/b/x.rego", Package: "data.compliance_framework.x", ID: "x"}}, "/b") - assert.Equal(t, "x", seed["_policy_path"]) - assert.Equal(t, "/b", labels["_policy_path"], "the plugin's labels are not modified") -} - -// TestGenerateResultsSeedsWithThePolicyID runs real bundles the way a plugin does: a vendor -// bundle without policy_id, and an override in another directory whose policy_id is the -// vendor module's legacy path, produce the same evidence UUID. Relative paths stay relative. -func TestGenerateResultsSeedsWithThePolicyID(t *testing.T) { - for _, relative := range []bool{false, true} { - name := "absolute" - if relative { - name = "relative" - } - t.Run(name, func(t *testing.T) { - root := t.TempDir() - if relative { - t.Chdir(root) - root = "." - } - vendor := filepath.Join(root, "vendor", "v0.2.0", "policies") - override := filepath.Join(root, "inline", "test", "current", "bundle") - legacyFile := vendor + "/ssh.rego" - writeModule(t, vendor, "ssh.rego", `package compliance_framework.ssh - -import rego.v1 - -title := "ssh" - -violation contains {"id": "v"} if input.bad -`) - writeModule(t, override, "ssh.rego", `package compliance_framework.ssh - -import rego.v1 - -policy_id := "`+legacyFile+`" - -title := "ssh (override)" - -violation contains {"id": "v"} if input.bad -`) - - generate := func(policyPath string) *proto.Evidence { - t.Helper() - processor := NewPolicyProcessor(hclog.NewNullLogger(), sshLabels(policyPath), nil, nil, nil, nil, nil, nil) - evidence, err := processor.GenerateResults(context.Background(), policyPath, map[string]any{"bad": true}) - require.NoError(t, err) - require.Len(t, evidence, 1) - return evidence[0] - } - legacy, overridden := generate(vendor), generate(override) - assert.Equal(t, legacy.UUID, overridden.UUID, "the override continues the vendor stream") - assert.NotContains(t, legacy.Labels, labelPolicyID) - assert.Equal(t, legacyFile, overridden.Labels[labelPolicyID]) - assert.Equal(t, override, overridden.Labels["_policy_path"]) - }) - } -} - -func writeModule(t *testing.T, dir, name, src string) { - t.Helper() - require.NoError(t, os.MkdirAll(dir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(src), 0o644)) -} - -// TestPolicyIDContinuesANonCleanLocalSource runs real bundles the way a plugin that labels -// _policy_path does: a local source configured with a non-clean path ("./policies") is -// passed to plugins literally, while OPA gives them the cleaned file ("policies/"). -// An override in an inline bundle whose policy_id is the literal "/" -// (R77) reproduces both seeds (policyeval.SeedPath), so it continues the vendor stream. -func TestPolicyIDContinuesANonCleanLocalSource(t *testing.T) { - for _, vendor := range []string{"./policies", "./policies/", "policies/", "policies"} { - t.Run(vendor, func(t *testing.T) { - t.Chdir(t.TempDir()) - override := filepath.Join(t.TempDir(), "inline", "b", "current", "bundle") - policyID := vendor + "/a.rego" - writeModule(t, "policies", "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\ntitle := \"a\"\n") - writeModule(t, override, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\npolicy_id := \""+policyID+"\"\n\ntitle := \"a\"\n") - - generate := func(policyPath string) *proto.Evidence { - t.Helper() - processor := NewPolicyProcessor(hclog.NewNullLogger(), sshLabels(policyPath), nil, nil, nil, nil, nil, nil) - evidence, err := processor.GenerateResults(context.Background(), policyPath, map[string]any{}) - require.NoError(t, err) - require.Len(t, evidence, 1) - return evidence[0] - } - legacy, overridden := generate(vendor), generate(override) - assert.Equal(t, vendor, legacy.Labels["_policy_path"]) - assert.Equal(t, legacy.UUID, overridden.UUID, "the override continues the vendor stream") - assert.Equal(t, policyID, overridden.Labels[labelPolicyID]) - assert.Equal(t, override, overridden.Labels["_policy_path"]) - - // The cleaned join reproduces the policy file but not the literal _policy_path. - if cleaned := path.Join(vendor, "a.rego"); cleaned != policyID { - writeModule(t, override, "a.rego", "package compliance_framework.a\n\nimport rego.v1\n\npolicy_id := \""+cleaned+"\"\n\ntitle := \"a\"\n") - assert.NotEqual(t, legacy.UUID, generate(override).UUID) - } - }) - } -} From cce28c69fcc18ddf4b676e173a844ca72e053a18 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 06:20:56 -0300 Subject: [PATCH 38/47] chore: pin api e69e286 Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8c68658..69f8551 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.1-0.20261001212246-52f0c63a6690 + github.com/compliance-framework/api v0.20.1-0.20261002091123-e69e2862d3ba 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 diff --git a/go.sum b/go.sum index f1299dc..a2f0293 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.1-0.20261001212246-52f0c63a6690 h1:zorc4g370DcLH9wtH1JV8280v733UPyx4jr1YTOtrOI= -github.com/compliance-framework/api v0.20.1-0.20261001212246-52f0c63a6690/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.20.1-0.20261002091123-e69e2862d3ba h1:oDQgc1Ymyqdb5Q0pv3N/2dXjzzSLyUpR4vlHbwGNc6Y= +github.com/compliance-framework/api v0.20.1-0.20261002091123-e69e2862d3ba/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= From dd238df6f799074cd97a26c008d93d48f8211001 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 08:47:58 -0300 Subject: [PATCH 39/47] refactor!: drop inline policy bundles, path shadowing and policy identity checks Inline policy bundles (policy_bundles in the config file or overlay, inline: policy entries) are removed, with everything that only served them: - cmd: inline bundle materialization and activation, path shadowing and plugin views, the duplicate-policy-identity check, the policy_bundles decoding of the config file (and its TOML/JSON path), and the set-form violation gate, which only judged inline bundles. Plugins run in the agent's working directory again. - internal/inlinepolicy and internal/policyview are deleted. - runner: WithPolicyRoot, the symlink resolution of policy paths and Source.BundleArtifact are removed; evidence records the policy source as on main. The shared artifact uploader and the _policy_path label fallback stay. - policy-manager: NewWithEvaluator and RiskTemplateError are removed; policy-manager.go equals main. The evidence seed golden test stays. The report keeps its policy-bundles inventory of the OCI and local sources each instance loads, and plugins[] with each plugin's lib-version. The inventory moves to policytree.Inventory. Co-Authored-By: Claude Opus 5.5 --- cmd/agent.go | 101 +--- cmd/agent_test.go | 36 +- cmd/artifacts.go | 63 ++- cmd/artifacts_test.go | 94 +++- cmd/compat.go | 108 ---- cmd/compat_test.go | 173 ------ cmd/config.go | 88 +-- cmd/config_golden_test.go | 2 +- cmd/config_test.go | 102 +--- cmd/identity.go | 239 -------- cmd/inline.go | 340 ------------ cmd/inline_test.go | 342 ------------ cmd/reconciler.go | 143 ++--- cmd/remote_test.go | 54 +- cmd/report.go | 71 ++- cmd/shadow.go | 296 ---------- cmd/shadow_test.go | 617 --------------------- go.mod | 2 +- internal/agentstate/store.go | 3 +- internal/inlinepolicy/activate_test.go | 228 -------- internal/inlinepolicy/check.go | 341 ------------ internal/inlinepolicy/contract.go | 337 ----------- internal/inlinepolicy/contract_test.go | 223 -------- internal/inlinepolicy/identity.go | 188 ------- internal/inlinepolicy/identity_test.go | 73 --- internal/inlinepolicy/inlinepolicy_test.go | 459 --------------- internal/inlinepolicy/materialize.go | 544 ------------------ internal/inlinepolicy/streams_test.go | 261 --------- internal/policytree/policytree.go | 60 +- internal/policytree/policytree_test.go | 50 ++ internal/policyview/policyview.go | 380 ------------- internal/policyview/policyview_test.go | 335 ----------- policy-manager/evidence_seed_test.go | 9 +- policy-manager/policy-manager.go | 26 +- runner/policy_artifacts.go | 10 +- runner/policy_artifacts_test.go | 33 -- runner/result.go | 61 +- runner/shadow_test.go | 92 --- runner/source_props_test.go | 58 +- 39 files changed, 436 insertions(+), 6206 deletions(-) delete mode 100644 cmd/compat.go delete mode 100644 cmd/compat_test.go delete mode 100644 cmd/identity.go delete mode 100644 cmd/inline.go delete mode 100644 cmd/inline_test.go delete mode 100644 cmd/shadow.go delete mode 100644 cmd/shadow_test.go delete mode 100644 internal/inlinepolicy/activate_test.go delete mode 100644 internal/inlinepolicy/check.go delete mode 100644 internal/inlinepolicy/contract.go delete mode 100644 internal/inlinepolicy/contract_test.go delete mode 100644 internal/inlinepolicy/identity.go delete mode 100644 internal/inlinepolicy/identity_test.go delete mode 100644 internal/inlinepolicy/inlinepolicy_test.go delete mode 100644 internal/inlinepolicy/materialize.go delete mode 100644 internal/inlinepolicy/streams_test.go delete mode 100644 internal/policyview/policyview.go delete mode 100644 internal/policyview/policyview_test.go delete mode 100644 runner/shadow_test.go diff --git a/cmd/agent.go b/cmd/agent.go index f34ef9b..6702305 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -34,7 +34,6 @@ import ( "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/internal/policyview" "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" @@ -96,21 +95,6 @@ type agentConfig struct { Plugins map[string]*agentPlugin `mapstructure:"plugins"` AgentEvidence *agentEvidenceConfig `mapstructure:"agent_evidence"` - // inlinePolicyDirs maps "inline:" policy entries to the path plugins receive: the - // extends source's path for a shadowed bundle (resolved in the plugin's view), else the - // bundle's stable path (.compliance-framework/policies/_inline//policies), which the - // reconciler points at inlineTrees before each run (R67). - inlinePolicyDirs map[string]string - // inlineTrees maps "inline:" policy entries to their materialized, content-addressed - // tree (inlinepolicy.Materialized.Dir). - inlineTrees map[string]string - // inlineDigests maps "inline:" policy entries to their tree digest, the evidence - // _policy_digest fallback when a bundle's artifact digest is not known. - inlineDigests map[string]string - // pluginViews are the working directories of the plugins that receive a shadowed inline - // bundle (path shadowing): the plugin process runs in its view, where the bundle's - // plugin path (the extends source's own path) resolves to the bundle's tree. - pluginViews map[string]*policyview.View // 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] @@ -261,7 +245,7 @@ with plugins to ensure continuous compliance.`, agentCmd.Flags().StringP("config", "c", "", "Location of config file") agentCmd.MarkFlagRequired("config") - agentCmd.Flags().String("state-dir", "", "Directory for this instance's state (instance ID, remote config cache, inline policies); overrides CCF_STATE_DIR. Default: .compliance-framework/state/") + 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") return agentCmd @@ -1442,15 +1426,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { return err } - workDir, err := config.pluginWorkDir(pluginName) - if err != nil { - ar.markPluginRunFinished(pluginName, err) - if evidenceErr := ar.sendAgentRunEvidenceAfterCompleteRun(ctx); evidenceErr != nil { - logger.Error("Error sending agent run evidence", "error", evidenceErr) - } - return err - } - runnerInstance, cleanupRunner, err := ar.getRunnerInstance(logger, source, pluginConfig.ProtocolVersion, workDir) + runnerInstance, cleanupRunner, err := ar.getRunnerInstance(logger, source, pluginConfig.ProtocolVersion) if err != nil { ar.markPluginRunFinished(pluginName, err) @@ -1483,7 +1459,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { for _, inputBundle := range pluginConfig.Policies { policyLocation := ar.policyLocations[string(inputBundle)] policyPaths = append(policyPaths, policyLocation) - policySources[policyLocation] = config.policySource(string(inputBundle), policyLocation) + policySources[policyLocation] = sourceOf(string(inputBundle), policyLocation) } // Create a new results helper for the plugin to send results back to @@ -1494,7 +1470,6 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { ) resultsHelper := runner.NewApiHelper(logger, client, labels, pluginName, runner.WithPolicyPaths(policyPaths), - runner.WithPolicyRoot(workDir), runner.WithSources(sourceOf(pluginConfig.Source, source), policySources), runner.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...), @@ -1587,17 +1562,12 @@ func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name policyPaths := make([]string, 0) policySources := make(map[string]runner.Source, len(plugin.Policies)) for _, inputBundle := range plugin.Policies { - if dir, ok := config.inlinePolicyDirs[string(inputBundle)]; ok { - policyPaths = append(policyPaths, dir) - policySources[dir] = config.policySource(string(inputBundle), dir) - continue - } policyLocation, err := ar.download(ctx, string(inputBundle), AgentPolicyDir, "policies", "", logger) if err != nil { return err } policyPaths = append(policyPaths, policyLocation) - policySources[policyLocation] = config.policySource(string(inputBundle), policyLocation) + policySources[policyLocation] = sourceOf(string(inputBundle), policyLocation) } platform := v1.Platform{ @@ -1627,11 +1597,7 @@ func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name return err } - workDir, err := config.pluginWorkDir(name) - if err != nil { - return err - } - runnerInstance, cleanupRunner, err := ar.getRunnerInstance(pluginLogger, pluginExecutable, plugin.ProtocolVersion, workDir) + runnerInstance, cleanupRunner, err := ar.getRunnerInstance(pluginLogger, pluginExecutable, plugin.ProtocolVersion) if err != nil { return err @@ -1650,7 +1616,6 @@ func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name ) resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), - runner.WithPolicyRoot(workDir), runner.WithSources(sourceOf(plugin.Source, pluginExecutable), policySources), runner.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...), @@ -1958,24 +1923,18 @@ func safePluginErrorFilename(pluginName string) string { return b.String() + "-error.txt" } -// pluginCommand is the command that starts the plugin binary at path in workDir ("" for the -// agent's own working directory). The binary is started by its absolute path, since a -// relative one would resolve against workDir: a plugin that receives a shadowed inline -// bundle runs in its view (path shadowing). Plugins get the host environment minus the -// agent's own API credentials (R26); go-plugin would otherwise append the whole environment. -func pluginCommand(path, workDir string) *exec.Cmd { - if abs, err := filepath.Abs(path); err == nil { - path = abs - } +// 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.Dir = workDir cmd.Env = pluginEnviron(os.Environ()) return cmd } -func (ar *AgentRunner) getRunnerInstance(logger hclog.Logger, path string, protocolVersion int32, workDir string) (runner.RunnerV2, func(), error) { +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, workDir) + cmd := pluginCommand(path) client := plugin.NewClient(&plugin.ClientConfig{ HandshakeConfig: runner.HandshakeConfig, Plugins: runner.PluginMap, @@ -2067,11 +2026,6 @@ func (ar *AgentRunner) DownloadPolicies(ctx context.Context) error { } for source := range policySources { - // Inline bundles were materialized by the reconciler; they are never downloaded. - if dir, ok := config.inlinePolicyDirs[source]; ok { - ar.policyLocations[source] = dir - continue - } out, err := ar.download(ctx, source, AgentPolicyDir, "policies", "", logger) if err != nil { @@ -2099,7 +2053,7 @@ func pluginEnviron(environ []string) []string { return out } -// Prefetch downloads every plugin and (non-inline) policy source of cfg and resolves plugin +// 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). @@ -2113,9 +2067,6 @@ func (ar *AgentRunner) Prefetch(ctx context.Context, cfg *agentConfig) error { for _, pluginConfig := range cfg.Plugins { pluginSources[pluginConfig.Source] = struct{}{} for _, policy := range pluginConfig.Policies { - if _, inline := cfg.inlinePolicyDirs[string(policy)]; inline { - continue - } policySources[string(policy)] = struct{}{} } } @@ -2171,8 +2122,8 @@ func (ar *AgentRunner) ReportStartupFailure(ctx context.Context, cfg *agentConfi } // 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 reconciler's -// plugin library check (R76) reads the binary Prefetch fetched. +// 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() @@ -2257,27 +2208,3 @@ func (ar *AgentRunner) trackPluginClient(client *plugin.Client) func() { func sourceOf(source, location string) runner.Source { return runner.Source{Reference: source, Digest: internal.SourceDigest(source, location)} } - -// pluginWorkDir returns the working directory plugin runs in: its view, made sure to exist -// and refreshed (path shadowing), or "" for the agent's own working directory. -func (c *agentConfig) pluginWorkDir(plugin string) (string, error) { - view := c.pluginViews[plugin] - if view == nil { - return "", nil - } - if err := view.Ensure(); err != nil { - return "", fmt.Errorf("plugin %s: %w", plugin, err) - } - return view.Dir, nil -} - -// policySource describes where the policy entry, which plugins receive at location, came from. -// An inline bundle is recorded as its entry (inline:) with its artifact digest, or its -// tree digest when the evaluation could not store the bundle; location is then the bundle's -// stable path (R67), the same key the plugin reports evaluations under. -func (c *agentConfig) policySource(entry, location string) runner.Source { - if _, inline := c.inlinePolicyDirs[entry]; inline { - return runner.Source{Reference: entry, Digest: c.inlineDigests[entry], BundleArtifact: true} - } - return sourceOf(entry, location) -} diff --git a/cmd/agent_test.go b/cmd/agent_test.go index 5bb8072..b04ab13 100644 --- a/cmd/agent_test.go +++ b/cmd/agent_test.go @@ -188,7 +188,7 @@ plugins: t.Fatalf("Error reading config: %v", err) } - _, err = baseFromViper(AgentCmd(), v, []byte(test.configYamlContent), "yaml") + _, 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) } @@ -335,7 +335,7 @@ plugins: t.Fatalf("Error reading config: %v", err) } - _, err = baseFromViper(AgentCmd(), v, nil, "yaml") + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatal("expected validate to fail when only one api auth env var is set") } @@ -442,7 +442,7 @@ func TestMergeConfig_RejectsUnsupportedExplicitProtocolVersion(t *testing.T) { t.Fatalf("Error reading config: %v", err) } - _, err = baseFromViper(AgentCmd(), v, nil, "yaml") + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatalf("Expected config validation to fail for unsupported protocol version") } @@ -461,7 +461,7 @@ func TestMergeConfig_RejectsExplicitZeroProtocolVersion(t *testing.T) { t.Fatalf("Error reading config: %v", err) } - _, err = baseFromViper(AgentCmd(), v, nil, "yaml") + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatalf("Expected config validation to fail for explicit zero protocol version") } @@ -480,7 +480,7 @@ func TestMergeConfig_RejectsNullPluginConfiguration(t *testing.T) { t.Fatalf("Error reading config: %v", err) } - _, err = baseFromViper(AgentCmd(), v, nil, "yaml") + _, err = baseFromViper(AgentCmd(), v, nil) if err == nil { t.Fatalf("Expected config validation to fail for null plugin configuration") } @@ -2061,7 +2061,7 @@ func mergeConfig(cmd *cobra.Command, v *viper.Viper) (*agentConfig, error) { if err != nil { return nil, err } - return toRuntime(declared, nil, nil) + return toRuntime(declared, nil) } // validateRuntimeForTest validates the declared equivalent of a runtime config. @@ -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/artifacts.go b/cmd/artifacts.go index b9df9ee..168841a 100644 --- a/cmd/artifacts.go +++ b/cmd/artifacts.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "maps" "net/http" "regexp" + "slices" "github.com/compliance-framework/agent/internal/policytree" "github.com/compliance-framework/agent/runner" @@ -14,8 +16,7 @@ import ( ) // Policy sources through the artifact store (R62). The report names every policy tree the -// agent runs (inline bundles, the vendor trees they extend, OCI and local sources) by tree -// digest. The agent also uploads each tree as a policy bundle artifact, through the same +// agent runs (OCI and local sources) by tree digest. The agent also uploads each tree as a policy bundle artifact, through the same // process-wide uploader the plugins' evidence uses, and reports the artifact digest next to // the tree digest, so the UI can read the sources (GET /api/artifacts/{digest}/files). // @@ -32,6 +33,59 @@ var artifactDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) // errTreeChanged: a local tree no longer has the digest it was inventoried with. var errTreeChanged = errors.New("the policy tree changed since it was inventoried") +// policyResolver returns the local directory of an OCI or local policy source, downloading it +// into the shared policy cache when needed. +type policyResolver func(ctx context.Context, source string) (dir string, err error) + +// sourceReports inventories the policy sources of runtime for the report, with the trees to +// upload as artifacts. A source that cannot be resolved or read is left out. +func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) ([]agentconfig.PolicyBundleReport, []artifactTree) { + sources := map[string]struct{}{} + for _, p := range runtime.Plugins { + for _, e := range p.Policies { + sources[string(e)] = struct{}{} + } + } + var reports []agentconfig.PolicyBundleReport + var trees []artifactTree + for _, source := range slices.Sorted(maps.Keys(sources)) { + dir, r, err := rc.sourceInventory(ctx, source) + if err != nil { + continue + } + reports = append(reports, r) + trees = append(trees, artifactTree{digest: r.Digest, dir: dir}) + } + return reports, trees +} + +// sourceInventory resolves an OCI or local policy source and inventories its tree. OCI trees +// are memoized per (source, dir); local trees are re-read. +func (rc *reconciler) sourceInventory(ctx context.Context, source string) (string, agentconfig.PolicyBundleReport, error) { + if rc.resolvePolicy == nil { + return "", agentconfig.PolicyBundleReport{}, errors.New("no policy resolver") + } + resolveCtx, cancel := context.WithTimeout(ctx, prepareNetworkTimeout) + dir, err := rc.resolvePolicy(resolveCtx, source) + cancel() + if err != nil { + return "", agentconfig.PolicyBundleReport{}, err + } + key := source + "\x00" + dir + if r, ok := rc.inventoryMemo[key]; ok { + return dir, r, nil + } + digest, files, err := policytree.Inventory(dir) + if err != nil { + return "", agentconfig.PolicyBundleReport{}, err + } + r := agentconfig.PolicyBundleReport{Source: source, Digest: digest, Files: files} + if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { + rc.inventoryMemo[key] = r + } + return dir, r, nil +} + // artifactTree is a policy tree the report names, and the directory it was read from. type artifactTree struct { digest string // agentconfig.BundleTreeDigest of the tree @@ -141,11 +195,6 @@ func (rc *reconciler) withArtifactDigests(bundles []agentconfig.PolicyBundleRepo out := append([]agentconfig.PolicyBundleReport(nil), bundles...) for i := range out { out[i].ArtifactDigest = rc.artifactMemo[out[i].Digest] - if out[i].Extends != nil { - ext := *out[i].Extends - ext.ArtifactDigest = rc.artifactMemo[ext.Digest] - out[i].Extends = &ext - } } return out } diff --git a/cmd/artifacts_test.go b/cmd/artifacts_test.go index 6b7310c..edbc694 100644 --- a/cmd/artifacts_test.go +++ b/cmd/artifacts_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "reflect" "strings" "testing" @@ -27,32 +28,66 @@ func tarDigest(t *testing.T, dir string) string { return "sha256:" + hex.EncodeToString(sum[:]) } -// TestArtifacts_ReportNamesTheSources_R62: the report carries the artifact digest of the -// inline tree and of the vendor tree it extends, and they are the digests a plugin's -// evaluation-time upload of the same directories produces. +// vendorBaseConfig runs plugin ssh with the OCI policy source ghcr.io/vendor/policies:v1. +const vendorBaseConfig = ` +daemon: true +api: + url: http://api.test + auth: + client_id: 123e4567-e89b-12d3-a456-426614174000 + client_secret: s3cret +remote_config: + mode: apply_safe +plugins: + ssh: + source: ghcr.io/compliance-framework/plugin-ssh:v1 + policies: ["ghcr.io/vendor/policies:v1"] +` + +// newVendorHarness is a remote harness on config whose policy resolver serves +// ghcr.io/vendor/policies:v1 from a temporary tree, which it returns. +func newVendorHarness(t *testing.T, config string) (*remoteHarness, string) { + t.Helper() + vendor := t.TempDir() + if err := os.WriteFile(filepath.Join(vendor, "banner.rego"), []byte("package compliance_framework.banner\n\nimport rego.v1\n\nviolation contains {\"remarks\": \"b\"} if not input.banner\n"), 0o644); err != nil { + t.Fatal(err) + } + h := newRemoteHarness(t, config) + h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { + if source == "ghcr.io/vendor/policies:v1" { + return vendor, nil + } + return "", errors.New("unknown source " + source) + } + return h, vendor +} + +// TestArtifacts_ReportNamesTheSources_R62: the report inventories the policy source and +// carries the artifact digest of its tree, the digest a plugin's evaluation-time upload of +// the same directory produces. func TestArtifacts_ReportNamesTheSources_R62(t *testing.T) { - h, vendor := newInlineHarness(t) + h, vendor := newVendorHarness(t, vendorBaseConfig) active := mustStartup(t, h.rc) r := h.remote.lastReport(t) - if len(r.PolicyBundles) != 1 || r.PolicyBundles[0].Extends == nil { + if len(r.PolicyBundles) != 1 { t.Fatalf("unexpected policy-bundles %+v", r.PolicyBundles) } b := r.PolicyBundles[0] - if want := tarDigest(t, active.runtime.inlinePolicyDirs["inline:ssh"]); b.ArtifactDigest != want { - t.Fatalf("inline artifact-digest = %q, want the digest of the stable path's tree %q", b.ArtifactDigest, want) + if b.Source != "ghcr.io/vendor/policies:v1" || len(b.Files) != 1 || b.Files[0].Path != "banner.rego" || b.Files[0].Package != "compliance_framework.banner" { + t.Fatalf("unexpected inventory %+v", b) } - if want := tarDigest(t, vendor); b.Extends.ArtifactDigest != want { - t.Fatalf("extends artifact-digest = %q, want %q", b.Extends.ArtifactDigest, want) + if want := tarDigest(t, vendor); b.ArtifactDigest != want { + t.Fatalf("artifact-digest = %q, want %q", b.ArtifactDigest, want) } - if n := h.remote.uploadCount(); n != 2 { + if n := h.remote.uploadCount(); n != 1 { t.Fatalf("expected one upload per tree, got %d", n) } // Memoized: later reports upload nothing. h.clock.Advance(reportResendInterval + 1) h.rc.maybeReport(context.Background(), active, nil) - if n := h.remote.uploadCount(); n != 2 { + if n := h.remote.uploadCount(); n != 1 { t.Fatalf("trees must be uploaded once, got %d uploads", n) } @@ -61,7 +96,7 @@ func TestArtifacts_ReportNamesTheSources_R62(t *testing.T) { if err != nil { t.Fatal(err) } - if len(r.PolicyBundles[0].Files) != 0 || r.PolicyBundles[0].ArtifactDigest != b.ArtifactDigest || r.PolicyBundles[0].Extends.ArtifactDigest != b.Extends.ArtifactDigest { + if len(r.PolicyBundles[0].Files) != 0 || r.PolicyBundles[0].ArtifactDigest != b.ArtifactDigest { t.Fatalf("truncation must keep artifact-digest: %+v", r.PolicyBundles[0]) } } @@ -71,7 +106,7 @@ func TestArtifacts_SourcesAreUploaded(t *testing.T) { if err := os.WriteFile(filepath.Join(local, "p.rego"), []byte("package compliance_framework.p\n\ntitle := \"p\"\n"), 0o644); err != nil { t.Fatal(err) } - h := newRemoteHarness(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["inline:ssh", "`+local+`"]`, 1)) + h := newRemoteHarness(t, strings.Replace(vendorBaseConfig, `policies: ["ghcr.io/vendor/policies:v1"]`, `policies: ["ghcr.io/vendor/policies:v1", "`+local+`"]`, 1)) h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { switch source { case "ghcr.io/vendor/policies:v1": @@ -112,7 +147,7 @@ func TestArtifacts_FailuresNeverRejectOrFail(t *testing.T) { "timeout": {err: context.DeadlineExceeded, wantRetried: true}, } { t.Run(name, func(t *testing.T) { - h, _ := newInlineHarness(t) + h, _ := newVendorHarness(t, vendorBaseConfig) failing := true h.remote.uploadErr = func(int) error { if failing { @@ -125,7 +160,7 @@ func TestArtifacts_FailuresNeverRejectOrFail(t *testing.T) { if r.Status == agentconfig.StatusRejected || r.Status == agentconfig.StatusFailed { t.Fatalf("an artifact failure must not reject or fail: %s/%s", r.Status, r.Reason) } - if r.PolicyBundles[0].ArtifactDigest != "" || r.PolicyBundles[0].Extends.ArtifactDigest != "" { + if r.PolicyBundles[0].ArtifactDigest != "" { t.Fatalf("a failed upload must leave artifact-digest empty: %+v", r.PolicyBundles[0]) } @@ -151,16 +186,37 @@ func TestArtifacts_FailuresNeverRejectOrFail(t *testing.T) { // TestArtifacts_ModeOffUploadsNothing: no report, no upload. func TestArtifacts_ModeOffUploadsNothing(t *testing.T) { - h, vendor := newInlineHarness(t) - h.writeConfig(t, strings.Replace(inlineBaseConfig, "mode: apply_safe", `mode: "off"`, 1)) - h.rc = h.newReconciler() - h.rc.resolvePolicy = func(context.Context, string) (string, error) { return vendor, nil } + h, _ := newVendorHarness(t, strings.Replace(vendorBaseConfig, "mode: apply_safe", `mode: "off"`, 1)) mustStartup(t, h.rc) if n := h.remote.uploadCount(); n != 0 { t.Fatalf("mode off must not upload, got %d", n) } } +// TestReport_PluginsCarryTheirLibVersion_R76: the report lists every plugin with the agent +// library its binary was built with; a version that cannot be read is reported as unknown. +func TestReport_PluginsCarryTheirLibVersion_R76(t *testing.T) { + config := vendorBaseConfig + ` aws: + source: ghcr.io/compliance-framework/plugin-aws:v1 +` + h, _ := newVendorHarness(t, config) + h.rc.pluginLib = func(_ context.Context, source string) (string, error) { + if source == "ghcr.io/compliance-framework/plugin-ssh:v1" { + return "v0.7.1", nil + } + return "", errors.New("not a Go binary") + } + mustStartup(t, h.rc) + got := h.remote.lastReport(t).Plugins + want := []agentconfig.PluginReport{ + {Name: "aws", Source: "ghcr.io/compliance-framework/plugin-aws:v1"}, + {Name: "ssh", Source: "ghcr.io/compliance-framework/plugin-ssh:v1", LibVersion: "v0.7.1"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("plugins = %+v, want %+v", got, want) + } +} + // TestArtifacts_SharedUploaderDedupesWithEvaluation: the reconciler and a plugin's API // helper share the process-wide uploader, so a tree the reconciler uploaded is not uploaded // again when the plugin's evidence references it. diff --git a/cmd/compat.go b/cmd/compat.go deleted file mode 100644 index fb856db..0000000 --- a/cmd/compat.go +++ /dev/null @@ -1,108 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "maps" - "slices" - - "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/pluginlib" - "github.com/compliance-framework/api/pkg/agentconfig" -) - -// Plugin compatibility (R76, R88). A plugin evaluates policies with the policy-manager it -// embeds, so what it can do with an inline bundle depends on the agent library it was built -// with, which the agent reads from the plugin binary's build info. Path shadowing -// (shadow.go) keeps vendor evidence streams with any library, so only one thing depends on -// it: a set-form violation (`violation contains ...`) crashes plugins older than -// pluginlib.MinViolationSet, an error when the overlay introduces it -// (plugin-lib-violation-set-unsupported). -// -// Inline bundles from the config file only warn (R34), and so does a library whose version -// is unknown (a local or replaced build, or no build info). The plugins report carries each -// plugin's lib-version. - -// 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) - -// pluginCompatibility returns the R76 problems of the plugins of runtime that use inline -// bundles, and the plugins report. origin tells overlay-introduced problems apart. Without a -// pluginLib function (tests) it checks and reports nothing. -func (rc *reconciler) pluginCompatibility(ctx context.Context, runtime *agentConfig, materialized map[string]*inlinepolicy.Materialized, origin policyOrigin) ([]agentconfig.PolicyError, []agentconfig.PluginReport) { - if rc.pluginLib == nil || runtime == nil { - return nil, nil - } - var problems []agentconfig.PolicyError - 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; treating it as unknown", "plugin", name, "source", p.Source, "error", err) - } - } - reports = append(reports, agentconfig.PluginReport{Name: name, Source: p.Source, LibVersion: version}) - if ok, _ := pluginlib.AtLeast(version, pluginlib.MinViolationSet); ok { - continue // set-form violations work - } - - var bundles []*inlinepolicy.Materialized - seen := map[string]bool{} - // The overlay brought the plugin and its inline policies together when it changed the - // plugin's source (a different build), gave it an inline entry, or changed a bundle - // it uses. - introduced := origin.pluginTouched(name, "source") - for _, e := range p.Policies { - if b, ok := agentconfig.InlineBundleName(string(e)); ok && materialized[b] != nil && !seen[b] { - seen[b] = true - bundles = append(bundles, materialized[b]) - introduced = introduced || origin.newEntry(name, string(e)) || origin.bundleTouched(b) - } - } - if len(bundles) == 0 { - continue - } - problems = append(problems, libProblems(name, version, bundles, introduced)...) - } - return problems, reports -} - -// libProblems are the problems of one plugin whose library is older than -// pluginlib.MinViolationSet or unknown. overlay is whether the overlay brought the plugin and -// these bundles together; only then is a known incompatibility an error. -func libProblems(plugin, version string, bundles []*inlinepolicy.Materialized, overlay bool) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - lib := version - if lib == "" { - lib = "unknown" - } - ok, known := pluginlib.AtLeast(version, pluginlib.MinViolationSet) - if ok { - return nil - } - severity := agentconfig.SeverityWarning - if known && overlay { - severity = agentconfig.SeverityError - } - unknownWhy := "is unknown (a local or replaced build, or no build info)" - if version != "" { - unknownWhy = fmt.Sprintf("%s has no release before it", version) - } - for _, m := range bundles { - for _, s := range m.SetViolations { - msg := fmt.Sprintf("plugin %s (agent lib %s) cannot evaluate violation as a set (`violation contains ...` needs agent ≥ %s) and would crash; use `violation[{...}] if { … }`", - plugin, lib, pluginlib.MinViolationSet) - if !known { - msg = fmt.Sprintf("plugin %s: its agent library version %s; plugins built on agent < %s crash on `violation contains ...`; use `violation[{...}] if { … }`", - plugin, unknownWhy, pluginlib.MinViolationSet) - } - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: s.Path, Row: s.Row, Col: s.Col, Severity: severity, - Code: agentconfig.PolicyCodePluginLibViolationSetUnsupported, Message: msg}) - } - } - return out -} diff --git a/cmd/compat_test.go b/cmd/compat_test.go deleted file mode 100644 index f49a862..0000000 --- a/cmd/compat_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package cmd - -import ( - "context" - "strings" - "testing" - - "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/api/pkg/agentconfig" -) - -// Plugin compatibility (R76, R88): the plugin's agent library decides whether it can -// evaluate set-form violations. - -// withPluginLib makes the harness's plugins report version as their agent library. -func withPluginLib(h *remoteHarness, version string) { - h.rc.pluginLib = func(context.Context, string) (string, error) { return version, nil } -} - -// inlineOverlay changes the inline bundle ssh, which plugin ssh uses (an overlay-introduced -// inline policy), with a set-form violation. -const inlineOverlay = `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\ntitle := \"extra v2\"\n\nviolation contains {\"id\": \"x\"} if input.max > data.max\n"}}}}` - -func policyErrorsWithCode(r agentconfig.Report, code string) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - for _, e := range r.PolicyErrors { - if e.Code == code { - out = append(out, e) - } - } - return out -} - -// rejectionErrors are the report's errors: a rejected revision's report also carries the -// running configuration's warnings. -func rejectionErrors(r agentconfig.Report, code string) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - for _, e := range policyErrorsWithCode(r, code) { - if e.Severity == agentconfig.SeverityError { - out = append(out, e) - } - } - return out -} - -func TestCompat(t *testing.T) { - const set = agentconfig.PolicyCodePluginLibViolationSetUnsupported - vendorPolicies := strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1"]`, 1) - trusted := strings.Replace(inlineBaseConfig, "mode: apply_safe", "mode: apply_safe\n trusted_sources: [\"ghcr.io/compliance-framework/*\"]", 1) - for _, tc := range []struct { - name string - config string - libs map[string]string // plugin source -> lib version; "*" for any other - overlay string - applied bool - want string // severity of the set-form problem at extra.rego ("" = none) - }{ - { - name: "overlay set form, lib v0.1.9", libs: map[string]string{"*": oldLib}, overlay: inlineOverlay, - want: agentconfig.SeverityError, - }, - { - name: "overlay set form, lib v0.7.1", libs: map[string]string{"*": "v0.7.1"}, overlay: inlineOverlay, applied: true, - }, - { - name: "overlay set form, unknown lib", libs: map[string]string{"*": ""}, overlay: inlineOverlay, applied: true, - want: agentconfig.SeverityWarning, - }, - { - name: "overlay assigns a set-form bundle, lib v0.7.2", config: vendorPolicies, libs: map[string]string{"*": "v0.7.2"}, - overlay: `{"plugins":{"ssh":{"policies":["inline:ssh"]}}}`, applied: true, - }, - { - name: "overlay assigns a set-form bundle, lib v0.7.0", config: vendorPolicies, libs: map[string]string{"*": "v0.7.0"}, - overlay: `{"plugins":{"ssh":{"policies":["inline:ssh"]}}}`, - want: agentconfig.SeverityError, - }, - { - name: "overlay moves the plugin to an old build", config: trusted, - libs: map[string]string{"ghcr.io/compliance-framework/plugin-ssh:v0": "v0.7.0", "*": "v0.9.0"}, - overlay: `{"plugins":{"ssh":{"source":"ghcr.io/compliance-framework/plugin-ssh:v0"}}}`, - want: agentconfig.SeverityError, - }, - { - name: "file set form, lib v0.7.0", libs: map[string]string{"*": "v0.7.0"}, applied: true, - want: agentconfig.SeverityWarning, - }, - } { - t.Run(tc.name, func(t *testing.T) { - config := tc.config - if config == "" { - config = inlineBaseConfig - } - h, _ := newInlineHarnessWith(t, config) - lib := func(source string) string { - if v, ok := tc.libs[source]; ok { - return v - } - return tc.libs["*"] - } - h.rc.pluginLib = func(_ context.Context, source string) (string, error) { return lib(source), nil } - if tc.overlay != "" { - h.remote.publish(1, tc.overlay) - } - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - switch { - case tc.overlay == "" && r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable: - t.Fatalf("the file configuration must load, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - case tc.overlay != "" && tc.applied && (active.overlay == nil || r.Status != agentconfig.StatusApplied): - t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - case !tc.applied && (active.overlay != nil || r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors): - t.Fatalf("expected rejected/policy-errors, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - got := policyErrorsWithCode(r, set) - if tc.want == agentconfig.SeverityError { - got = rejectionErrors(r, set) - } - switch { - case tc.want == "" && len(got) != 0: - t.Fatalf("no %s expected, got %+v", set, got) - case tc.want == "": - case len(got) != 1 || got[0].Severity != tc.want || got[0].Path != "extra.rego" || got[0].Row == 0 || !strings.Contains(got[0].Message, "plugin ssh"): - t.Fatalf("expected one located %s %s naming the plugin, got %+v", tc.want, set, r.PolicyErrors) - case !strings.Contains(got[0].Message, "violation[{...}] if"): - t.Fatalf("the set-form problem must name the fix: %s", got[0].Message) - } - if len(r.Plugins) != 1 || r.Plugins[0].Name != "ssh" || r.Plugins[0].LibVersion != lib(r.Plugins[0].Source) { - t.Fatalf("plugins report = %+v", r.Plugins) - } - }) - } -} - -// TestLibProblems: per plugin library and origin, for any bundle (shadowed or not). -func TestLibProblems(t *testing.T) { - set := []inlinepolicy.Site{{Path: "s.rego", Row: 1, Col: 1}} - shadowed := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "oci"}, Shadowed: true, SetViolations: set} - absolute := &inlinepolicy.Materialized{Name: "b", Extends: &agentconfig.PolicyBundleExtendsReport{Source: "/abs"}, SetViolations: set} - plain := &inlinepolicy.Materialized{Name: "b"} - for _, tc := range []struct { - name string - version string - m *inlinepolicy.Materialized - overlay bool - want string // severity of the set-form problem, "" = none - }{ - {"nothing to check, v0.1.9", oldLib, plain, true, ""}, - {"shadowed, v0.1.9", oldLib, shadowed, true, agentconfig.SeverityError}, - {"not shadowed, v0.1.9", oldLib, absolute, true, agentconfig.SeverityError}, - {"file, v0.1.9", oldLib, absolute, false, agentconfig.SeverityWarning}, - {"v0.7.2", "v0.7.2", absolute, true, ""}, - {"unknown", "", absolute, true, agentconfig.SeverityWarning}, - } { - t.Run(tc.name, func(t *testing.T) { - var got string - for _, e := range libProblems("ssh", tc.version, []*inlinepolicy.Materialized{tc.m}, tc.overlay) { - if e.Code != agentconfig.PolicyCodePluginLibViolationSetUnsupported { - t.Fatalf("unexpected problem %+v", e) - } - got = e.Severity - } - if got != tc.want { - t.Fatalf("got %q, want %q", got, tc.want) - } - }) - } - - untagged := libProblems("ssh", "v0.0.0-20261001110117-f88bde9ee37a", []*inlinepolicy.Materialized{absolute}, true) - if len(untagged) != 1 || untagged[0].Severity != agentconfig.SeverityWarning || !strings.Contains(untagged[0].Message, "v0.0.0-20261001110117-f88bde9ee37a has no release before it") { - t.Fatalf("an untagged build only warns and names its version, got %+v", untagged) - } -} diff --git a/cmd/config.go b/cmd/config.go index ebb95d8..75ea156 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -2,7 +2,6 @@ package cmd import ( "bytes" - "encoding/json" "errors" "fmt" "maps" @@ -13,10 +12,8 @@ import ( "strings" "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" "github.com/spf13/viper" - "sigs.k8s.io/yaml" ) // baseSnapshot is one load of the local configuration: the file merged with CLI flags and @@ -26,7 +23,7 @@ import ( // 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; PolicyBundles decoded from raw bytes + 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. @@ -72,17 +69,8 @@ func isToleratedFileRule(e agentconfig.FieldError) bool { // - 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. -// -// policy_bundles is a new feature, so its env-location errors stay fatal. func isWarnOnlyFileRule(e agentconfig.FieldError) bool { - switch { - case e.Path == "/verbosity": - return true - case e.Code == agentconfig.FieldCodeEnvLocation: - segs := agentconfig.SplitPointer(e.Path) - return len(segs) == 0 || segs[0] != "policy_bundles" - } - return false + return e.Path == "/verbosity" || e.Code == agentconfig.FieldCodeEnvLocation } // newAgentViper builds a fresh viper for one load (R32): the watcher goroutine and the loader @@ -149,7 +137,6 @@ func applyFlagOverrides(cmd *cobra.Command, v *viper.Viper) error { // 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. -// PolicyBundles are not decoded here (see decodePolicyBundles). func declaredFromViper(cmd *cobra.Command, v *viper.Viper) (agentconfig.Config, error) { if err := applyFlagOverrides(cmd, v); err != nil { return agentconfig.Config{}, err @@ -206,48 +193,6 @@ func checkExplicitZeroProtocol(v *viper.Viper, declared agentconfig.Config) erro return fmt.Errorf("plugin %s has unsupported protocol_version=0; supported values are %d and %d", names[0], DefaultProtocolVersion, RunnerV2ProtocolVersion) } -// decodePolicyBundles decodes the file's policy_bundles block WITHOUT viper (HLD §3.5.6): -// viper lowercases keys and splits them on dots, which would corrupt module paths such as -// "Policies/Max.Auth.rego". -func decodePolicyBundles(raw []byte, ext string) (map[string]*agentconfig.PolicyBundle, error) { - var jsonDoc []byte - switch ext { - case "yaml", "yml": - converted, err := yaml.YAMLToJSON(raw) - if err != nil { - return nil, fmt.Errorf("decode policy_bundles: %w", err) - } - jsonDoc = converted - case "json": - jsonDoc = raw - case "toml": - var doc map[string]any - if err := toml.Unmarshal(raw, &doc); err != nil { - return nil, fmt.Errorf("decode policy_bundles: %w", err) - } - converted, err := json.Marshal(map[string]any{"policy_bundles": doc["policy_bundles"]}) - if err != nil { - return nil, fmt.Errorf("decode policy_bundles: %w", err) - } - jsonDoc = converted - default: - return nil, nil - } - - if len(bytes.TrimSpace(jsonDoc)) == 0 || bytes.Equal(bytes.TrimSpace(jsonDoc), []byte("null")) { - return nil, nil - } - var doc struct { - PolicyBundles map[string]*agentconfig.PolicyBundle `json:"policy_bundles"` - } - dec := json.NewDecoder(bytes.NewReader(jsonDoc)) - dec.UseNumber() - if err := dec.Decode(&doc); err != nil { - return nil, fmt.Errorf("decode policy_bundles: %w", err) - } - return doc.PolicyBundles, nil -} - // 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. @@ -281,29 +226,15 @@ func loadBase(cmd *cobra.Command, configPath string) (*baseSnapshot, error) { if err := v.ReadConfig(bytes.NewReader(raw)); err != nil { return nil, err } - return baseFromViper(cmd, v, raw, configExt(configPath)) + 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, ext string) (*baseSnapshot, error) { +func baseFromViper(cmd *cobra.Command, v *viper.Viper, raw []byte) (*baseSnapshot, error) { declared, err := declaredFromViper(cmd, v) if err != nil { return nil, err } - // Only a file that sets policy_bundles takes the non-viper decode, so every other file - // loads exactly as on main (e.g. a YAML .nan, which JSON cannot represent). - if v.IsSet("policy_bundles") { - switch ext { - case "yaml", "yml", "json", "toml": - default: - return nil, fmt.Errorf("policy_bundles is only supported in yaml, json and toml config files") - } - bundles, err := decodePolicyBundles(raw, ext) - if err != nil { - return nil, err - } - declared.PolicyBundles = bundles - } base := &baseSnapshot{ declared: declared, @@ -454,13 +385,12 @@ func basePluginConfigValue(base agentconfig.Config, plugin, key string) string { // 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, inlineDirs map[string]string, skip map[string]string) (*agentConfig, error) { +func toRuntime(c agentconfig.Config, skip map[string]string) (*agentConfig, error) { out := &agentConfig{ - Daemon: c.Daemon, - Verbosity: c.Verbosity, - Plugins: map[string]*agentPlugin{}, - inlinePolicyDirs: inlineDirs, - remote: c.EffectiveRemoteConfig(), + Daemon: c.Daemon, + Verbosity: c.Verbosity, + Plugins: map[string]*agentPlugin{}, + remote: c.EffectiveRemoteConfig(), } if c.API != nil { out.ApiConfig = &apiConfig{Url: c.API.URL} diff --git a/cmd/config_golden_test.go b/cmd/config_golden_test.go index 9232810..594d127 100644 --- a/cmd/config_golden_test.go +++ b/cmd/config_golden_test.go @@ -122,7 +122,7 @@ func loadGoldenFixture(t *testing.T, yaml string) *agentConfig { if err != nil { t.Fatalf("load fixture: %v", err) } - config, err := toRuntime(base.declared, nil, base.skip) + config, err := toRuntime(base.declared, base.skip) if err != nil { t.Fatalf("runtime fixture: %v", err) } diff --git a/cmd/config_test.go b/cmd/config_test.go index d2ace22..7aa9d93 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -6,7 +6,6 @@ import ( "os" "path/filepath" "reflect" - "strings" "testing" "github.com/compliance-framework/api/pkg/agentconfig" @@ -33,64 +32,6 @@ func mustLoadBase(t *testing.T, ext, content string) *baseSnapshot { return base } -func TestDecodePolicyBundles_PreservesModuleKeys(t *testing.T) { - tests := []struct { - ext string - content string - }{ - { - ext: "yaml", - content: ` -api: - url: http://localhost:8080 -policy_bundles: - ssh: - modules: - Policies/Max.Auth.rego: | - package compliance_framework.max_auth - a.b/c.rego: "package compliance_framework.c" -`, - }, - { - ext: "json", - content: `{ - "api": {"url": "http://localhost:8080"}, - "policy_bundles": {"ssh": {"modules": { - "Policies/Max.Auth.rego": "package compliance_framework.max_auth\n", - "a.b/c.rego": "package compliance_framework.c" - }}} -}`, - }, - { - ext: "toml", - content: ` -[api] -url = "http://localhost:8080" - -[policy_bundles.ssh.modules] -"Policies/Max.Auth.rego" = """package compliance_framework.max_auth -""" -"a.b/c.rego" = "package compliance_framework.c" -`, - }, - } - for _, tt := range tests { - t.Run(tt.ext, func(t *testing.T) { - base := mustLoadBase(t, tt.ext, tt.content) - bundle := base.declared.PolicyBundles["ssh"] - if bundle == nil { - t.Fatalf("expected ssh bundle, got %#v", base.declared.PolicyBundles) - } - if got := bundle.Modules["Policies/Max.Auth.rego"]; got != "package compliance_framework.max_auth\n" { - t.Fatalf("mixed-case module lost or altered: %q (modules %v)", got, bundle.Modules) - } - if got := bundle.Modules["a.b/c.rego"]; got != "package compliance_framework.c" { - t.Fatalf("dotted module lost or altered: %q (modules %v)", got, bundle.Modules) - } - }) - } -} - const weakTypedConfig = ` api: url: http://localhost:8080 @@ -116,7 +57,7 @@ func TestLoadBase_WeakDecodingUnchanged(t *testing.T) { 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, nil, base.skip) + rt, err := toRuntime(base.declared, base.skip) if err != nil { t.Fatal(err) } @@ -130,7 +71,7 @@ func TestLoadBase_WeakDecodingUnchanged(t *testing.T) { // 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, nil) + fileOnly, err := toRuntime(base.declared, nil) if err != nil { t.Fatal(err) } @@ -138,7 +79,7 @@ func TestWeakDecoding_SurvivesUnrelatedOverlay(t *testing.T) { if err != nil { t.Fatal(err) } - withOverlay, err := toRuntime(merged, nil, nil) + withOverlay, err := toRuntime(merged, nil) if err != nil { t.Fatal(err) } @@ -195,7 +136,7 @@ plugins: 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, nil, base.skip) + rt, err := toRuntime(base.declared, base.skip) if err != nil { t.Fatal(err) } @@ -259,7 +200,7 @@ func TestLoadBase_FileOriginWarnOnly(t *testing.T) { 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, nil, base.skip) + rt, err := toRuntime(base.declared, base.skip) if err != nil { t.Fatal(err) } @@ -280,24 +221,18 @@ func TestLoadBase_FileOriginWarnOnly(t *testing.T) { t.Fatalf("overlay-introduced values must be strict, got %#v", p) } }) - t.Run("policy_bundles env-location stays fatal", func(t *testing.T) { - errs := agentconfig.ValidationErrors{{Path: "/policy_bundles/b/modules/x.rego", Code: agentconfig.FieldCodeEnvLocation, Message: "env"}} - if p := partitionByOrigin(errs, nil); len(p.fatal) != 1 { - t.Fatalf("policy_bundles is a new feature and stays strict, got %#v", p) - } - }) } -// TestLoadBase_NoPolicyBundlesTakesMainPath: without policy_bundles the file never goes through -// the JSON conversion, so YAML that JSON cannot represent loads as on main. -func TestLoadBase_NoPolicyBundlesTakesMainPath(t *testing.T) { +// 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.PolicyBundles != nil { - t.Fatalf("no bundles expected, got %v", base.declared.PolicyBundles) + 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:\nplugins:\n ssh:\n source: ./plugin-ssh\n") - if base.declared.PolicyBundles != nil { - t.Fatalf("a null policy_bundles must load as none, got %v", base.declared.PolicyBundles) + 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) } } @@ -366,7 +301,7 @@ plugins: github: source: ./plugin-github `) - rt, err := toRuntime(base.declared, nil, nil) + rt, err := toRuntime(base.declared, nil) if err != nil { t.Fatal(err) } @@ -389,7 +324,7 @@ plugins: source: ./plugin-b protocol_version: 2 `) - rt, err := toRuntime(base.declared, nil, nil) + rt, err := toRuntime(base.declared, nil) if err != nil { t.Fatal(err) } @@ -400,10 +335,3 @@ plugins: t.Fatalf("pinned plugin: %#v", p) } } - -func TestLoadBase_PolicyBundlesUnsupportedFormat(t *testing.T) { - _, err := loadBase(AgentCmd(), writeConfigFile(t, "env", "API.URL=http://localhost:8080\nPOLICY_BUNDLES=x\n")) - if err == nil || !strings.Contains(err.Error(), "policy_bundles is only supported") { - t.Fatalf("expected unsupported-format error, got %v", err) - } -} diff --git a/cmd/identity.go b/cmd/identity.go deleted file mode 100644 index fc48897..0000000 --- a/cmd/identity.go +++ /dev/null @@ -1,239 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "maps" - "slices" - "strings" - - "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/api/pkg/agentconfig" -) - -// Evidence identity across a plugin's policy paths (R66, R75). A plugin evaluates each of -// its policy paths separately and records evidence for every policy module of each, so a -// policy loaded twice is reported twice: -// -// - duplicate-policy-identity: two modules from different policy paths have the same -// evidence identity: the same seed, or the same package and bundle-relative file (an -// inline bundle listed next to the source it extends); -// - duplicate-policy-package (R66): the same package from two policy paths otherwise. -// -// The first is an error when the overlay introduces it (it gives the plugin one of the -// policy entries involved, or changes one of the inline bundles involved) and a warning when -// it comes from the config file (R34). The last is always a warning. Plugins that use no inline bundle are not -// checked: their policy paths are the file's and the vendors' business. - -// policyOrigin tells overlay-introduced policy problems from file-origin ones (R34). -type policyOrigin struct { - // touched are the pointers the overlay changed (nil without an overlay). - touched []string - // filePolicies are the policy entries each plugin has in the config file. - filePolicies map[string]map[string]bool -} - -func newPolicyOrigin(file agentconfig.Config, touched []string) policyOrigin { - o := policyOrigin{touched: touched, filePolicies: map[string]map[string]bool{}} - for name, p := range file.Plugins { - if p == nil { - continue - } - entries := map[string]bool{} - for _, e := range p.Policies { - entries[string(e)] = true - } - o.filePolicies[name] = entries - } - return o -} - -// newEntry reports whether the overlay gave plugin the policy entry: the file does not. -func (o policyOrigin) newEntry(plugin, entry string) bool { - return o.touched != nil && !o.filePolicies[plugin][entry] -} - -// bundleTouched reports whether the overlay changed inline bundle name. -func (o policyOrigin) bundleTouched(name string) bool { - return touchedByOverlay(agentconfig.Pointer("policy_bundles", name), o.touched) -} - -// pluginTouched reports whether the overlay changed field of plugin. -func (o policyOrigin) pluginTouched(plugin, field string) bool { - return touchedByOverlay(agentconfig.Pointer("plugins", plugin, field), o.touched) -} - -// introduces reports whether the overlay brought module m to plugin: it added m's policy -// entry to the plugin, or changed m's inline bundle. -func (o policyOrigin) introduces(plugin string, m loadedModule) bool { - return o.newEntry(plugin, m.entry) || (m.bundle != "" && o.bundleTouched(m.bundle)) -} - -// codeDuplicatePolicyPackage is the PolicyError code of R66. -const codeDuplicatePolicyPackage = "duplicate-policy-package" - -// loadedModule is one policy module a plugin loads. -type loadedModule struct { - entry string // the plugin's policy entry - bundle string // the inline bundle's name, "" for other sources - pluginPath string // the path the agent passes the plugin for entry - id inlinepolicy.ModuleIdentity - authored bool - // seedFile and seedPath are the module's evidence seed (policy_file, _policy_path). - seedFile, seedPath string -} - -func newLoadedModule(entry, bundle, pluginPath string, id inlinepolicy.ModuleIdentity, authored bool) loadedModule { - m := loadedModule{entry: entry, bundle: bundle, pluginPath: pluginPath, id: id, authored: authored} - m.seedFile, m.seedPath = inlinepolicy.SeedOf(id, pluginPath) - return m -} - -func (m loadedModule) where() string { - return fmt.Sprintf("%s in %s", m.id.Path, m.entry) -} - -// policyIdentities returns the R66/R75 problems of every enabled plugin that uses an inline -// bundle. origin tells overlay-introduced problems apart. A source that cannot be resolved -// here is skipped (prefetch reports it). -func (rc *reconciler) policyIdentities(ctx context.Context, resolved agentconfig.Config, skip map[string]string, materialized map[string]*inlinepolicy.Materialized, origin policyOrigin) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { - p := resolved.Plugins[pluginName] - if p == nil || !p.IsEnabled() { - continue - } - if _, skipped := skip[pluginName]; skipped { - continue - } - var modules []loadedModule - usesInline := false - seenEntry := map[string]bool{} - for _, e := range p.Policies { - entry := string(e) - if seenEntry[entry] { - continue - } - seenEntry[entry] = true - if name, ok := agentconfig.InlineBundleName(e); ok { - m := materialized[name] - if m == nil { - continue - } - usesInline = true - for _, id := range m.Identities { - modules = append(modules, newLoadedModule(entry, name, m.Path, id, m.Authored[id.Path])) - } - continue - } - dir, ids, err := rc.sourceIdentities(ctx, entry) - if err != nil { - continue - } - for _, id := range ids { - modules = append(modules, newLoadedModule(entry, "", dir, id, false)) - } - } - if !usesInline { - continue - } - severity := func(a, b loadedModule) string { - if origin.introduces(pluginName, a) || origin.introduces(pluginName, b) { - return agentconfig.SeverityError - } - return agentconfig.SeverityWarning - } - out = append(out, identityProblems(pluginName, modules, severity)...) - } - return out -} - -// identityProblems compares every pair of modules of one plugin. -func identityProblems(pluginName string, modules []loadedModule, severity func(a, b loadedModule) string) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - identityPackages := map[string]bool{} // packages with an identity problem across paths - samePackage := map[string][]loadedModule{} - report := func(a, b loadedModule, sev, code, msg string) { - at := b - if at.bundle == "" || (a.bundle != "" && a.authored && !b.authored) { - at = a - } - out = append(out, agentconfig.PolicyError{Bundle: at.bundle, Path: at.id.Path, Severity: sev, Code: code, - Message: fmt.Sprintf("plugin %s: %s", pluginName, msg)}) - } - for j, b := range modules { - for _, a := range modules[:j] { - switch { - case a.entry == b.entry: - continue - case a.id.Package == b.id.Package && sameSeed(a, b): - identityPackages[a.id.Package] = true - report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyIdentity, - fmt.Sprintf("%s and %s write to the same evidence stream (package %s), so each evidence is recorded twice; load only one of them: if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", a.where(), b.where(), a.id.Package)) - case a.id.Package == b.id.Package && a.id.Path == b.id.Path: - identityPackages[a.id.Package] = true - report(a, b, severity(a, b), agentconfig.PolicyCodeDuplicatePolicyIdentity, - fmt.Sprintf("%s is loaded from both %s and %s (package %s), so the plugin records its evidence twice, in two streams; load only one of them: if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", a.id.Path, a.entry, b.entry, a.id.Package)) - case a.id.Package == b.id.Package: - samePackage[a.id.Package] = append(samePackage[a.id.Package], a, b) - } - } - } - for _, pkg := range slices.Sorted(maps.Keys(samePackage)) { - if identityPackages[pkg] { - continue - } - var at loadedModule - var entries []string - seen := map[string]bool{} - for _, m := range samePackage[pkg] { - if !seen[m.entry] { - seen[m.entry] = true - entries = append(entries, m.entry) - } - if at.bundle == "" && m.bundle != "" { - at = m - } - } - if at.entry == "" { - at = samePackage[pkg][0] - } - out = append(out, agentconfig.PolicyError{ - Bundle: at.bundle, - Path: at.id.Path, - Severity: agentconfig.SeverityWarning, - Code: codeDuplicatePolicyPackage, - Message: fmt.Sprintf("plugin %s: package %s is defined in more than one of its policy paths (%s), so the plugin records its evidence more than once; if one is an inline bundle that extends the other, replace the source with the bundle instead of listing both", - pluginName, pkg, strings.Join(entries, ", ")), - }) - } - return out -} - -func sameSeed(a, b loadedModule) bool { - return a.seedFile == b.seedFile && a.seedPath == b.seedPath -} - -// sourceIdentities resolves an OCI or local policy source and returns the path plugins -// receive for it and its modules' identities. OCI trees are memoized per (source, dir). -func (rc *reconciler) sourceIdentities(ctx context.Context, source string) (string, []inlinepolicy.ModuleIdentity, error) { - dir, _, err := rc.sourceInventory(ctx, source) - if err != nil { - return "", nil, err - } - key := source + "\x00" + dir - if ids, ok := rc.identityMemo[key]; ok { - return dir, ids, nil - } - ids, err := inlinepolicy.TreeIdentities(dir) - if err != nil { - return "", nil, err - } - if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { - if len(rc.identityMemo) >= artifactMemoLimit { - rc.identityMemo = map[string][]inlinepolicy.ModuleIdentity{} - } - rc.identityMemo[key] = ids - } - return dir, ids, nil -} diff --git a/cmd/inline.go b/cmd/inline.go deleted file mode 100644 index ad5e28a..0000000 --- a/cmd/inline.go +++ /dev/null @@ -1,340 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "maps" - "path/filepath" - "slices" - "strings" - - "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/policyview" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/pkg/agentconfig/regocheck" -) - -// inlineGCKeepPerBundle is how many materialized versions of each bundle GC keeps besides the -// active ones. -const inlineGCKeepPerBundle = 5 - -// inlineLinksDir is where the stable links of inline bundles live, relative to the agent's -// working directory like the OCI policy cache next to it, so plugins receive a bundle that -// is not shadowed as .compliance-framework/policies/_inline//policies. The leading -// underscore keeps it apart from the OCI cache: OCI repository path components start with -// [a-z0-9], so no image extracts to _inline. -var inlineLinksDir = filepath.Join(AgentPolicyDir, "_inline") - -// inlineLayout is where inline bundles are materialized (under the state dir, R31) and -// where plugins receive them when they are not shadowed. -func (rc *reconciler) inlineLayout() inlinepolicy.Layout { - links := rc.inlineLinks - if links == "" { - links = inlineLinksDir - } - return inlinepolicy.Layout{Store: filepath.Join(rc.store.Dir(), "inline"), Links: links} -} - -// prepareInline is prepare step 8 (G3b): the parse-level checks on every bundle, then -// materialize each bundle an enabled plugin references and check it per (plugin, path) the -// way the plugin will load it. Any error rejects the revision (policy-errors); warnings are -// kept for the report. No network is touched before the Classify gate: extends trees are -// fetched here, after it. -func (rc *reconciler) prepareInline(ctx context.Context, resolved agentconfig.Config, skip map[string]string, origin policyOrigin) (inlineResult, *applyError) { - var res inlineResult - if len(resolved.PolicyBundles) == 0 { - return res, nil - } - - static := regocheck.ValidatePolicyBundles(resolved.PolicyBundles) - if agentconfig.HasPolicyErrors(static) { - return res, policyRejection(static) - } - res.warnings = append(res.warnings, static...) - - // Bundles referenced by the plugins that will run. - refs := map[string]bool{} - for name, p := range resolved.Plugins { - if p == nil || !p.IsEnabled() { - continue - } - if _, skipped := skip[name]; skipped { - continue - } - for _, e := range p.Policies { - if b, ok := agentconfig.InlineBundleName(e); ok { - refs[b] = true - } - } - } - if len(refs) == 0 { - return res, nil - } - - // Path shadowing is decided before materializing: it sets the path plugins receive. - plan := rc.planShadowing(ctx, resolved, skip, refs) - - materialized := map[string]*inlinepolicy.Materialized{} - var problems []agentconfig.PolicyError - for _, name := range slices.Sorted(maps.Keys(refs)) { - m, err := inlinepolicy.Materialize(ctx, rc.inlineLayout(), name, resolved.PolicyBundles[name], rc.boundedResolver(), - inlinepolicy.Options{Shadow: plan.shadow[name]}) - var perrs inlinepolicy.PolicyErrors - switch { - case errors.As(err, &perrs): - problems = append(problems, perrs...) - continue - case errors.Is(err, inlinepolicy.ErrResolve): - return res, failed(agentconfig.ReasonDownloadFailed, err) - case err != nil: - return res, failed(agentconfig.ReasonInternal, err) - } - materialized[name] = m - res.warnings = append(res.warnings, m.Warnings...) - } - if agentconfig.HasPolicyErrors(problems) { - return res, policyRejection(append(problems, res.warnings...)) - } - - // One compile unit per (plugin, policy path) (R21): plugins with different policy_data - // are checked separately. - for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { - p := resolved.Plugins[pluginName] - if p == nil || !p.IsEnabled() { - continue - } - if _, skipped := skip[pluginName]; skipped { - continue - } - for _, e := range p.Policies { - name, ok := agentconfig.InlineBundleName(e) - if !ok || materialized[name] == nil { - continue - } - m := materialized[name] - problems = append(problems, inlinepolicy.Check(ctx, inlinepolicy.CheckInput{ - Plugin: pluginName, - Bundle: name, - PolicyDir: m.Dir, - Authored: m.Authored, - AuthoredTests: m.AuthoredTests, - PolicyData: p.PolicyData, - })...) - } - } - for _, name := range slices.Sorted(maps.Keys(materialized)) { - problems = append(problems, inlinepolicy.OverrideStreams(materialized[name])...) - } - problems = append(problems, unshadowedWarnings(resolved, skip, plan, materialized)...) - problems = append(problems, rc.policyIdentities(ctx, resolved, skip, materialized, origin)...) - if agentconfig.HasPolicyErrors(problems) { - return res, policyRejection(append(problems, res.warnings...)) - } - res.warnings = append(res.warnings, problems...) - res.warnings = dedupePolicyErrors(res.warnings) - agentconfig.SortPolicyErrors(res.warnings) - - views, err := rc.buildViews(plan, materialized) - if err != nil { - return res, failed(agentconfig.ReasonInternal, err) - } - res.views = views - - res.materialized = materialized - res.dirs = map[string]string{} - res.trees = map[string]string{} - res.digests = map[string]string{} - for _, name := range slices.Sorted(maps.Keys(materialized)) { - m := materialized[name] - entry := agentconfig.InlineSourcePrefix + name - res.dirs[entry] = m.Path - res.trees[entry] = m.Dir - res.digests[entry] = m.Digest - res.reports = append(res.reports, agentconfig.PolicyBundleReport{ - Source: entry, - Digest: m.Digest, - Extends: m.Extends, - Files: m.Files, - }) - res.artifacts = append(res.artifacts, artifactTree{digest: m.Digest, dir: m.Dir}) - if m.Extends != nil { - res.artifacts = append(res.artifacts, artifactTree{digest: m.Extends.Digest, dir: m.ExtendsDir}) - } - } - return res, nil -} - -// dedupePolicyErrors drops exact duplicates (the per-plugin checks of one bundle repeat -// plugin-independent findings) and a warning superseded by an error with the same bundle, -// path and code (the API's static check may only warn about what the agent, which sees the -// whole tree, rejects). -func dedupePolicyErrors(errs []agentconfig.PolicyError) []agentconfig.PolicyError { - type site struct{ bundle, path, code string } - isError := map[site]bool{} - for _, e := range errs { - if e.Code != "" && e.Severity == agentconfig.SeverityError { - isError[site{e.Bundle, e.Path, e.Code}] = true - } - } - seen := map[agentconfig.PolicyError]bool{} - out := make([]agentconfig.PolicyError, 0, len(errs)) - for _, e := range errs { - if seen[e] || (e.Severity == agentconfig.SeverityWarning && e.Code != "" && isError[site{e.Bundle, e.Path, e.Code}]) { - continue - } - seen[e] = true - out = append(out, e) - } - return out -} - -func policyRejection(errs []agentconfig.PolicyError) *applyError { - errs = dedupePolicyErrors(errs) - agentconfig.SortPolicyErrors(errs) - var only []agentconfig.PolicyError - for _, e := range errs { - if e.Severity == agentconfig.SeverityError { - only = append(only, e) - } - } - aerr := rejected(agentconfig.ReasonPolicyErrors, inlinepolicy.PolicyErrors(only)) - aerr.PolicyErrors = errs - return aerr -} - -// sourceReports inventories the non-inline policy paths of runtime for the report, with the -// trees to upload as artifacts. -func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) ([]agentconfig.PolicyBundleReport, []artifactTree) { - sources := map[string]struct{}{} - for _, p := range runtime.Plugins { - for _, e := range p.Policies { - if _, inline := runtime.inlinePolicyDirs[string(e)]; !inline { - sources[string(e)] = struct{}{} - } - } - } - var reports []agentconfig.PolicyBundleReport - var trees []artifactTree - for _, source := range slices.Sorted(maps.Keys(sources)) { - dir, r, err := rc.sourceInventory(ctx, source) - if err != nil { - continue - } - reports = append(reports, r) - trees = append(trees, artifactTree{digest: r.Digest, dir: dir}) - } - return reports, trees -} - -// sourceInventory resolves an OCI or local policy source and inventories its tree. OCI trees -// are memoized per (source, dir); local trees are re-read. -func (rc *reconciler) sourceInventory(ctx context.Context, source string) (string, agentconfig.PolicyBundleReport, error) { - resolve := rc.boundedResolver() - if resolve == nil { - return "", agentconfig.PolicyBundleReport{}, errors.New("no policy resolver") - } - dir, err := resolve(ctx, source) - if err != nil { - return "", agentconfig.PolicyBundleReport{}, err - } - key := source + "\x00" + dir - if r, ok := rc.inventoryMemo[key]; ok { - return dir, r, nil - } - digest, files, err := inlinepolicy.Inventory(dir) - if err != nil { - return "", agentconfig.PolicyBundleReport{}, err - } - r := agentconfig.PolicyBundleReport{Source: source, Digest: digest, Files: files} - if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { - rc.inventoryMemo[key] = r - } - return dir, r, nil -} - -// boundedResolver wraps resolvePolicy with prepareNetworkTimeout per call (nil when unset). -func (rc *reconciler) boundedResolver() inlinepolicy.Resolver { - if rc.resolvePolicy == nil { - return nil - } - return func(ctx context.Context, source string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, prepareNetworkTimeout) - defer cancel() - return rc.resolvePolicy(ctx, source) - } -} - -// afterStartup removes the materialized inline bundles startup does not use. -func (rc *reconciler) afterStartup(active *candidate) { - rc.gcInline(active) -} - -// gcInline removes materialized inline bundles that none of keep, the running, pending, -// starting or fallback candidate, nor a bundle's stable link uses, and that are not among -// the newest inlineGCKeepPerBundle per bundle, and the plugin views no such candidate uses. -// It runs after startup and right after every swap, so a long-running daemon does not -// accumulate one directory per revision; the configuration still draining after a swap is -// the running candidate, so its trees and views are kept. It holds inlineMu, so it never -// races activateInline. -func (rc *reconciler) gcInline(keep ...*candidate) { - if !rc.store.Writable() { - return - } - rc.mu.Lock() - keep = append(keep, rc.active, rc.pending, rc.starting, rc.fallback) - rc.mu.Unlock() - - rc.inlineMu.Lock() - defer rc.inlineMu.Unlock() - dirs := map[string]struct{}{} - views := map[string]struct{}{} - found := false - for _, c := range keep { - if c == nil || c.runtime == nil { - continue - } - found = true - for _, dir := range c.runtime.inlineTrees { - dirs[dir] = struct{}{} - } - for _, v := range c.runtime.pluginViews { - views[v.Dir] = struct{}{} - } - } - if !found { - return - } - if err := inlinepolicy.GC(rc.inlineLayout(), dirs, inlineGCKeepPerBundle); err != nil { - rc.logger.Warn("Could not clean up old inline policy bundles", "error", err) - } - if root, err := filepath.Abs(rc.viewsRoot()); err == nil { - if err := policyview.GC(root, views); err != nil { - rc.logger.Warn("Could not clean up old plugin views", "error", err) - } - } -} - -// activateInline points the stable path of each inline bundle c uses at c's tree (R67), -// then creates the views of the plugins that receive a shadowed bundle. A view that cannot -// be created is only logged: pluginWorkDir ensures it again before each run of its plugin -// and fails just that run, so one plugin's view never stops the configuration (views are -// content-addressed and survive restarts, so failing here could crash-loop the agent). -func (rc *reconciler) activateInline(c *candidate) error { - if c == nil || c.runtime == nil || len(c.runtime.inlineTrees) == 0 { - return nil - } - rc.inlineMu.Lock() - defer rc.inlineMu.Unlock() - var errs []error - for _, entry := range slices.Sorted(maps.Keys(c.runtime.inlineTrees)) { - name := strings.TrimPrefix(entry, agentconfig.InlineSourcePrefix) - errs = append(errs, inlinepolicy.Activate(rc.inlineLayout(), name, c.runtime.inlineTrees[entry])) - } - for _, plugin := range slices.Sorted(maps.Keys(c.runtime.pluginViews)) { - view := c.runtime.pluginViews[plugin] - if err := view.Ensure(); err != nil { - rc.logger.Warn("Could not prepare a plugin's view; its runs fail until it is fixed", "plugin", plugin, "view", view.Dir, "error", err) - } - } - return errors.Join(errs...) -} diff --git a/cmd/inline_test.go b/cmd/inline_test.go deleted file mode 100644 index ab9b86c..0000000 --- a/cmd/inline_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/compliance-framework/api/pkg/agentconfig" -) - -const inlineBaseConfig = ` -daemon: true -api: - url: http://api.test - auth: - client_id: 123e4567-e89b-12d3-a456-426614174000 - client_secret: s3cret -remote_config: - mode: apply_safe -plugins: - ssh: - source: ghcr.io/compliance-framework/plugin-ssh:v1 - policies: ["inline:ssh"] - policy_data: - max: 3 -policy_bundles: - ssh: - extends: ghcr.io/vendor/policies:v1 - modules: - extra.rego: | - package compliance_framework.extra - - import rego.v1 - - title := "extra" - - violation contains {"remarks": "x"} if input.max > data.max -` - -func newInlineHarness(t *testing.T) (*remoteHarness, string) { - t.Helper() - return newInlineHarnessWith(t, inlineBaseConfig) -} - -// newInlineHarnessWith is newInlineHarness on config, which may use the vendor source -// ghcr.io/vendor/policies:v1. -func newInlineHarnessWith(t *testing.T, config string) (*remoteHarness, string) { - t.Helper() - vendor := t.TempDir() - if err := os.WriteFile(filepath.Join(vendor, "banner.rego"), []byte("package compliance_framework.banner\n\nimport rego.v1\n\nviolation contains {\"remarks\": \"b\"} if not input.banner\n"), 0o644); err != nil { - t.Fatal(err) - } - h := newRemoteHarness(t, config) - h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { - if source == "ghcr.io/vendor/policies:v1" { - return vendor, nil - } - return "", errors.New("unknown source " + source) - } - return h, vendor -} - -func TestInline_FileBundleMaterializedAndReported(t *testing.T) { - h, _ := newInlineHarness(t) - active := mustStartup(t, h.rc) - dir, ok := active.runtime.inlinePolicyDirs["inline:ssh"] - if !ok || dir != filepath.Join(h.dir, "policies", "_inline", "ssh", "policies") { - t.Fatalf("plugins must receive the bundle's stable link: %v", active.runtime.inlinePolicyDirs) - } - if tree := active.runtime.inlineTrees["inline:ssh"]; !strings.HasPrefix(tree, filepath.Join(h.dir, "state", "inline", "ssh")) { - t.Fatalf("inline bundle not materialized under the state dir: %s", tree) - } - for _, f := range []string{"banner.rego", "extra.rego"} { - if _, err := os.Stat(filepath.Join(dir, f)); err != nil { - t.Fatalf("%s missing from the materialized tree: %v", f, err) - } - } - r := h.remote.lastReport(t) - if len(r.PolicyBundles) != 1 || r.PolicyBundles[0].Source != "inline:ssh" || r.PolicyBundles[0].Extends == nil || len(r.PolicyBundles[0].Extends.Files) != 1 || len(r.PolicyBundles[0].Files) != 2 { - t.Fatalf("unexpected policy-bundles %+v", r.PolicyBundles) - } -} - -func TestInline_OverlayParseErrorKeepsRunning(t *testing.T) { - h, _ := newInlineHarness(t) - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nviolation contains x if {"}}}}`) - active := mustStartup(t, h.rc) - if active.overlay != nil { - t.Fatal("a policy with a parse error must not be applied") - } - r := h.remote.lastReport(t) - if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors { - t.Fatalf("expected rejected/policy-errors, got %s/%s", r.Status, r.Reason) - } - found := false - for _, e := range r.PolicyErrors { - if e.Severity == agentconfig.SeverityError && e.Path == "extra.rego" && e.Row > 0 && e.Col > 0 { - found = true - } - } - if !found { - t.Fatalf("expected a located policy error, got %+v", r.PolicyErrors) - } -} - -func TestInline_TransitiveDeniedBuiltinRejected(t *testing.T) { - h, vendor := newInlineHarness(t) - if err := os.WriteFile(filepath.Join(vendor, "lib.rego"), []byte("package ccf_libs.net\n\nimport rego.v1\n\nfetch(u) := http.send({\"method\": \"GET\", \"url\": u})\n"), 0o644); err != nil { - t.Fatal(err) - } - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\nimport data.ccf_libs.net\n\ntitle := \"extra\"\n\nviolation contains {\"remarks\": r} if r := net.fetch(\"http://x\")\n"}}}}`) - mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors || !strings.Contains(*r.Error, "http.send") { - t.Fatalf("expected the helper-wrapped http.send to be rejected, got %s/%s %v", r.Status, r.Reason, derefString(r.Error)) - } -} - -func TestInline_DownloadPoliciesNeverDownloadsInline(t *testing.T) { - ar := NewAgentRunner() - ar.UpdateConfig(&agentConfig{ - Plugins: map[string]*agentPlugin{ - "ssh": {Source: "/tmp/plugin", Policies: []agentPolicy{"inline:ssh"}}, - }, - inlinePolicyDirs: map[string]string{"inline:ssh": "/materialized/ssh"}, - }) - if err := ar.DownloadPolicies(context.Background()); err != nil { - t.Fatalf("an inline source must not be downloaded: %v", err) - } - if got := ar.policyLocations["inline:ssh"]; got != "/materialized/ssh" { - t.Fatalf("policy location = %q", got) - } -} - -// TestInline_GCAfterSwap bounds the materialized directories of a long-running daemon: GC runs -// after every swap, not only at startup. -func TestInline_GCAfterSwap(t *testing.T) { - h, _ := newInlineHarness(t) - h.remote.publish(0, `{}`) - mustStartup(t, h.rc) - for rev := int64(1); rev <= 10; rev++ { - overlay := fmt.Sprintf(`{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\n# rev %d\ntitle := \"extra\"\n\nviolation contains {\"remarks\": \"x\"} if input.max > data.max\n"}}}}`, rev) - h.remote.publish(rev, overlay) - if got := h.poll(t); got.overlay == nil || got.overlay.Revision != rev { - r := h.remote.lastReport(t) - t.Fatalf("revision %d did not apply: %s/%s %s", rev, r.Status, r.Reason, derefString(r.Error)) - } - } - entries, err := os.ReadDir(filepath.Join(h.rc.inlineLayout().Store, "ssh")) - if err != nil { - t.Fatal(err) - } - dirs := 0 - for _, e := range entries { - if e.IsDir() { - dirs++ - } - } - if dirs > inlineGCKeepPerBundle+2 { - t.Fatalf("expected at most %d materialized dirs after GC, found %d", inlineGCKeepPerBundle+2, dirs) - } - // The stable path points at the running tree. - running := h.rc.running().runtime - got, err := filepath.EvalSymlinks(running.inlinePolicyDirs["inline:ssh"]) - if err != nil { - t.Fatal(err) - } - want, _ := filepath.EvalSymlinks(running.inlineTrees["inline:ssh"]) - if got != want { - t.Fatalf("the stable path resolves to %s, want the running tree %s", got, want) - } -} - -// TestInline_DuplicateIdentityAcrossPolicyPaths_R75: a plugin that loads both the vendor -// source and an inline bundle extending it from the config file gets a warning naming both -// paths (R34: file problems only warn); the revision still applies. -func TestInline_DuplicateIdentityAcrossPolicyPaths_R75(t *testing.T) { - h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1", "inline:ssh"]`, 1)) - mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable { - t.Fatalf("a duplicate from the file is a warning only, got %s/%s", r.Status, r.Reason) - } - var found bool - for _, e := range r.PolicyErrors { - if e.Code == agentconfig.PolicyCodeDuplicatePolicyIdentity { - found = e.Severity == agentconfig.SeverityWarning && e.Bundle == "ssh" && e.Path == "banner.rego" && - strings.Contains(e.Message, "compliance_framework.banner") && - strings.Contains(e.Message, "ghcr.io/vendor/policies:v1") && strings.Contains(e.Message, "inline:ssh") - } - if e.Code == codeDuplicatePolicyPackage { - t.Fatalf("the identity problem replaces the package warning for the same package: %+v", e) - } - } - if !found { - t.Fatalf("expected a duplicate-policy-identity warning naming both paths, got %+v", r.PolicyErrors) - } - - // Replacing the source with the inline bundle (the R22 swap) clears it. - h, _ = newInlineHarness(t) - mustStartup(t, h.rc) - for _, e := range h.remote.lastReport(t).PolicyErrors { - if e.Code == agentconfig.PolicyCodeDuplicatePolicyIdentity || e.Code == codeDuplicatePolicyPackage { - t.Fatalf("no warning expected without the duplicate path: %+v", e) - } - } -} - -// TestInline_OverlayDuplicateIdentityRejected_R75: an overlay that lists the inline bundle -// next to the source it extends is rejected. -func TestInline_OverlayDuplicateIdentityRejected_R75(t *testing.T) { - h, _ := newInlineHarness(t) - h.remote.publish(1, `{"plugins":{"ssh":{"policies":["ghcr.io/vendor/policies:v1","inline:ssh"]}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay != nil || r.Status != agentconfig.StatusRejected || r.Reason != agentconfig.ReasonPolicyErrors { - t.Fatalf("expected rejected/policy-errors, got %s/%s", r.Status, r.Reason) - } - if errs := rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyIdentity); len(errs) != 1 || errs[0].Path != "banner.rego" { - t.Fatalf("expected one duplicate-policy-identity error, got %+v", r.PolicyErrors) - } -} - -// TestInline_StablePathSwapsOnlyBetweenRuns_R67: a revision prepared while a run is in -// progress does not move the stable path under it; the run loop points it at the new tree -// before the next run, and back at the previous tree when it falls back. -func TestInline_StablePathSwapsOnlyBetweenRuns_R67(t *testing.T) { - h, _ := newInlineHarness(t) - h.remote.publish(0, `{}`) - active, err := h.rc.startup(context.Background()) - if err != nil { - t.Fatal(err) - } - stable := active.runtime.inlinePolicyDirs["inline:ssh"] - resolve := func() string { - t.Helper() - got, err := filepath.EvalSymlinks(stable) - if err != nil { - t.Fatal(err) - } - return got - } - treeOf := func(cfg *agentConfig) string { - got, _ := filepath.EvalSymlinks(cfg.inlineTrees["inline:ssh"]) - return got - } - - var runs []string - var firstTree string - errStop := errors.New("stop") - err = h.rc.run(active, func(ctx context.Context, cfg *agentConfig) error { - if cfg.inlinePolicyDirs["inline:ssh"] != stable { - t.Errorf("plugins must always get the stable path, got %s", cfg.inlinePolicyDirs["inline:ssh"]) - } - if resolve() != treeOf(cfg) { - t.Errorf("run %d: the stable path does not point at the run's tree", len(runs)+1) - } - runs = append(runs, treeOf(cfg)) - switch len(runs) { - case 1: - firstTree = resolve() - // A new revision arrives and is prepared while this run is in progress. - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\ntitle := \"extra v2\"\n"}}}}`) - h.rc.reconcile(context.Background(), triggerPoll) - if ctx.Err() == nil { - t.Error("the swap must cancel the running configuration") - } - if resolve() != firstTree { - t.Error("the stable path moved while the previous configuration was still running") - } - return nil - case 2: - if resolve() == firstTree { - t.Error("the new run must see the new tree") - } - return errStop // fall back to the first configuration - default: - if resolve() != firstTree { - t.Error("the fallback must point the stable path back at its tree") - } - return errStop - } - }) - if !errors.Is(err, errStop) || len(runs) != 3 { - t.Fatalf("run returned %v after %d runs", err, len(runs)) - } -} - -// TestInline_EvidenceSourceIsTheBundle (design §13.4, #96/#97): an inline bundle's evidence -// records the bundle entry and its digest, keyed by the stable path plugins receive (R67), -// and an OCI source keeps its own source. -func TestInline_EvidenceSourceIsTheBundle(t *testing.T) { - h, _ := newInlineHarness(t) - h.remote.publish(0, `{}`) - mustStartup(t, h.rc) - cfg := h.rc.running().runtime - stable := cfg.inlinePolicyDirs["inline:ssh"] - if !strings.HasSuffix(filepath.ToSlash(stable), "/_inline/ssh/policies") { - t.Fatalf("plugins must receive the stable path, got %s", stable) - } - got := cfg.policySource("inline:ssh", stable) - var tree string - for _, b := range h.rc.running().bundles { - if b.Source == "inline:ssh" { - tree = b.Digest - } - } - if got.Reference != "inline:ssh" || !got.BundleArtifact || got.Digest == "" || got.Digest != tree { - t.Fatalf("inline source = %+v, want inline:ssh with the reported tree digest %q as fallback", got, tree) - } - if oci := cfg.policySource("ghcr.io/vendor/policies:v1", t.TempDir()); oci.Reference != "ghcr.io/vendor/policies:v1" || oci.BundleArtifact { - t.Fatalf("OCI source = %+v", oci) - } -} - -// TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75: an overlay that only reorders a -// plugin's policies does not introduce the file's duplicate, so it still applies. -func TestInline_FileDuplicateStaysAWarningUnderAnOverlay_R75(t *testing.T) { - h, _ := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, `policies: ["inline:ssh"]`, `policies: ["ghcr.io/vendor/policies:v1", "inline:ssh"]`, 1)) - withPluginLib(h, "v0.7.0") // file bundles on an old plugin only warn too - h.remote.publish(1, `{"plugins":{"ssh":{"policies":["inline:ssh","ghcr.io/vendor/policies:v1"]}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay == nil || r.Status != agentconfig.StatusApplied { - t.Fatalf("expected the reorder to apply, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - for _, code := range []string{agentconfig.PolicyCodeDuplicatePolicyIdentity, agentconfig.PolicyCodePluginLibViolationSetUnsupported} { - got := policyErrorsWithCode(r, code) - if len(got) == 0 { - t.Fatalf("expected a %s warning, got %+v", code, r.PolicyErrors) - } - for _, e := range got { - if e.Severity != agentconfig.SeverityWarning { - t.Fatalf("%s must stay a warning: %+v", code, e) - } - } - } -} diff --git a/cmd/reconciler.go b/cmd/reconciler.go index 8997c2e..b00fb07 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -17,8 +17,6 @@ import ( "time" "github.com/compliance-framework/agent/internal/agentstate" - "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/policyview" runnerpkg "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" @@ -42,8 +40,8 @@ var ( // failedRetryMin / failedRetryMax bound the retry of a failed/* revision. failedRetryMin = time.Minute failedRetryMax = 10 * time.Minute - // prepareNetworkTimeout bounds each network step of prepare (plugin/policy prefetch, an - // extends tree, a report inventory), so a hung registry cannot stall the reconciler. A + // prepareNetworkTimeout bounds each network step of prepare (plugin/policy prefetch, a + // report inventory), 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 ) @@ -59,7 +57,7 @@ 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, inline dirs set + 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. @@ -210,18 +208,13 @@ type reconciler struct { // lookupEnv resolves ${env:NAME} placeholders (a test seam). lookupEnv func(string) (string, bool) // resolvePolicy returns the policy root of an OCI or local policy source (downloading it - // into the shared cache); it serves inline bundles' extends and the report inventory. - resolvePolicy inlinepolicy.Resolver - // inlineLinks overrides inlineLinksDir, where plugins receive inline bundles (a test - // seam: the default is relative to the working directory). - inlineLinks string + // into the shared cache) for the report inventory. + resolvePolicy policyResolver // pluginLib reads the agent library version of a prefetched plugin source (R76); - // nil skips the plugin compatibility checks and report. + // nil leaves the plugins report empty. pluginLib pluginLibFunc - // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"), and - // identityMemo their modules' evidence identities. + // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"). inventoryMemo map[string]agentconfig.PolicyBundleReport - identityMemo map[string][]inlinepolicy.ModuleIdentity // artifacts is the process-wide artifact uploader (shared with the plugins' API helpers). artifacts *runnerpkg.ArtifactUploader // artifactMemo maps a policy tree digest to the digest of its uploaded artifact ("" when @@ -229,16 +222,10 @@ type reconciler struct { artifactMemo map[string]string now func() time.Time - mu sync.Mutex // guards active, pending, starting, fallback, cancelRun - active *candidate - pending *candidate - // starting is the candidate the run loop took from pending and is about to bind; - // fallback is the one it falls back to if the running one fails. GC keeps their trees. - starting *candidate - fallback *candidate + mu sync.Mutex // guards active, pending, cancelRun + active *candidate + pending *candidate cancelRun context.CancelFunc - // inlineMu serializes activating inline trees (run loop) with GC (reconciler goroutine). - inlineMu sync.Mutex // Everything below is owned by the reconciler goroutine (startup runs before loop). base *baseSnapshot @@ -279,7 +266,6 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor loggedOnce: map[string]bool{}, inventoryMemo: map[string]agentconfig.PolicyBundleReport{}, - identityMemo: map[string][]inlinepolicy.ModuleIdentity{}, artifacts: runnerpkg.NewArtifactUploader(), artifactMemo: map[string]string{}, } @@ -427,7 +413,6 @@ func (rc *reconciler) startup(ctx context.Context) (*candidate, error) { rc.lastOutcome = outcome } rc.maybeReport(ctx, active, rc.lastOutcome) - rc.afterStartup(active) return active, nil } @@ -633,19 +618,10 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent } } - origin := newPolicyOrigin(base.declared, touched) - inline, aerr := rc.prepareInline(ctx, resolved, part.skip, origin) - if aerr != nil { - return nil, aerr - } - - runtime, err := toRuntime(resolved, inline.dirs, part.skip) + runtime, err := toRuntime(resolved, part.skip) if err != nil { return nil, failed(agentconfig.ReasonInvalidConfig, err) } - runtime.inlineTrees = inline.trees - runtime.inlineDigests = inline.digests - runtime.pluginViews = inline.views prefetchCtx, cancelPrefetch := context.WithTimeout(ctx, prepareNetworkTimeout) err = rc.runner.Prefetch(prefetchCtx, runtime) cancelPrefetch() @@ -654,18 +630,12 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent aerr.runtime = runtime return nil, aerr } - compat, plugins := rc.pluginCompatibility(ctx, runtime, inline.materialized, origin) - if agentconfig.HasPolicyErrors(compat) { - return nil, policyRejection(append(compat, inline.warnings...)) - } - if len(compat) > 0 { - inline.warnings = dedupePolicyErrors(append(inline.warnings, compat...)) - agentconfig.SortPolicyErrors(inline.warnings) - } + var bundles []agentconfig.PolicyBundleReport + var trees []artifactTree + var plugins []agentconfig.PluginReport if rcfg.Mode != agentconfig.ModeOff { - reports, trees := rc.sourceReports(ctx, runtime) - inline.reports = append(inline.reports, reports...) - inline.artifacts = append(inline.artifacts, trees...) + bundles, trees = rc.sourceReports(ctx, runtime) + plugins = rc.pluginReports(ctx, runtime) } // The digest is over the UNRESOLVED form with the same masking as the reported effective @@ -677,34 +647,19 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent } runtime.setSync(meta) return &candidate{ - base: base, - overlay: ov, - declared: declared, - runtime: runtime, - digest: digest, - identity: candidateIdentity(declared, inline.trees), - bundles: inline.reports, - trees: inline.artifacts, - warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), - policyWarnings: inline.warnings, - plugins: plugins, + base: base, + overlay: ov, + declared: declared, + runtime: runtime, + digest: digest, + identity: candidateIdentity(declared), + bundles: bundles, + trees: trees, + warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), + plugins: plugins, }, nil } -// inlineResult is what the inline bundle step contributes to a candidate (G3b). -type inlineResult struct { - dirs map[string]string // "inline:" -> the stable path plugins receive - trees map[string]string // "inline:" -> the materialized tree - digests map[string]string // "inline:" -> the materialized tree's digest - reports []agentconfig.PolicyBundleReport - artifacts []artifactTree - warnings []agentconfig.PolicyError - // materialized are the bundles the enabled plugins use, by name. - materialized map[string]*inlinepolicy.Materialized - // views are the working directories of the plugins that receive a shadowed bundle. - views map[string]*policyview.View -} - // 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) { @@ -759,13 +714,12 @@ func overlayValidationError(err error) *applyError { return rejected(reason, errs) } -// candidateIdentity hashes the declared config and the materialized inline trees (not the -// stable paths, which never change), so a different tree is a different configuration. -func candidateIdentity(c agentconfig.Config, inlineDirs map[string]string) string { +// 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"` - InlineDirs map[string]string `json:"inline_dirs,omitempty"` - }{c, inlineDirs}) + Config agentconfig.Config `json:"config"` + }{c}) if err != nil { raw = []byte(err.Error()) } @@ -779,7 +733,6 @@ func (rc *reconciler) bind(active *candidate, cancel context.CancelFunc) { rc.mu.Lock() defer rc.mu.Unlock() rc.active = active - rc.starting = nil rc.cancelRun = cancel if rc.pending != nil { cancel() @@ -818,13 +771,6 @@ func (rc *reconciler) record(c *candidate) *candidate { return c } -// running returns the candidate the run loop has bound (it may still be draining after a swap). -func (rc *reconciler) running() *candidate { - rc.mu.Lock() - defer rc.mu.Unlock() - return rc.active -} - // swap makes next the pending candidate and cancels the running one. func (rc *reconciler) swap(next *candidate) { rc.mu.Lock() @@ -840,25 +786,9 @@ func (rc *reconciler) takePending() *candidate { defer rc.mu.Unlock() next := rc.pending rc.pending = nil - if next != nil { - rc.starting = next - } return next } -// start activates c's inline bundles (activateInline), then records c as the running -// candidate and fallback as the one to fall back to. The run loop calls it only once the -// previous configuration's run returned (R67). starting and fallback are set first, so GC -// keeps the trees of both throughout. -func (rc *reconciler) start(c, fallback *candidate, cancel context.CancelFunc) error { - rc.mu.Lock() - rc.starting, rc.fallback = c, fallback - rc.mu.Unlock() - err := rc.activateInline(c) - rc.bind(c, cancel) - return err -} - // current returns the candidate that is running, or about to run when a swap is pending. func (rc *reconciler) current() *candidate { rc.mu.Lock() @@ -875,18 +805,14 @@ func (rc *reconciler) run(active *candidate, run runFunc) error { var previous, failedRun *candidate for { runCtx, cancel := context.WithCancel(context.Background()) - runErr := rc.start(active, previous, cancel) + 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 } - if runErr != nil { - rc.logger.Error("Could not activate the inline policy bundles", "error", runErr) - } else { - runErr = run(runCtx, active.runtime) - } + runErr := run(runCtx, active.runtime) reload := runCtx.Err() != nil cancel() active = rc.record(active) @@ -1016,7 +942,7 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { 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 (and re-running inline policy checks) on every poll (§5.4, G3.4). + // re-preparing it on every poll (§5.4, G3.4). rc.maybeReport(ctx, active, rc.lastOutcome) return } @@ -1046,7 +972,6 @@ func (rc *reconciler) reconcile(ctx context.Context, t trigger) { } rc.logger.Info("Applying the new configuration", "revision", revisionForLog(target)) rc.swap(cand) - rc.gcInline(rc.running(), active, cand) rc.maybeReport(ctx, cand, rc.lastOutcome) } diff --git a/cmd/remote_test.go b/cmd/remote_test.go index a75e4e3..a9f5a85 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -12,6 +12,7 @@ import ( "path/filepath" "reflect" "runtime" + "slices" "strings" "sync" "testing" @@ -189,7 +190,6 @@ func newRemoteHarness(t *testing.T, content string) *remoteHarness { // 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.inlineLinks = filepath.Join(h.dir, "policies", "_inline") rc.newRemote = func(agentconfig.Config) remoteAPI { return h.remote } rc.now = h.clock.Now rc.lookupEnv = func(string) (string, bool) { return "", false } @@ -209,9 +209,7 @@ func mustStartup(t *testing.T, rc *reconciler) *candidate { if err != nil { t.Fatalf("startup: %v", err) } - if err := rc.start(active, nil, func() {}); err != nil { - t.Fatalf("start: %v", err) - } + rc.bind(active, func() {}) return active } @@ -220,9 +218,7 @@ func (h *remoteHarness) poll(t *testing.T) *candidate { t.Helper() h.rc.reconcile(context.Background(), triggerPoll) if next := h.rc.takePending(); next != nil { - if err := h.rc.start(next, nil, func() {}); err != nil { - t.Fatalf("start: %v", err) - } + h.rc.bind(next, func() {}) } return h.rc.current() } @@ -439,24 +435,41 @@ func TestRemoteErrors_Backoffs(t *testing.T) { } func TestReport_OversizedIsTruncated(t *testing.T) { - big := strings.Repeat("# padding\n", 30000) // ~300 KiB per module - modules := map[string]string{} - for i := 0; i < 14; i++ { - modules[fmt.Sprintf("m%02d.rego", i)] = "package compliance_framework.m\n" + big - } - report := agentconfig.Report{Mode: "apply_safe", Status: "applied"} - doc := agentconfig.Config{PolicyBundles: map[string]*agentconfig.PolicyBundle{"b": {Modules: modules}}} - report.Base = marshalRaw(doc) - report.Effective = marshalRaw(doc) + // 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)}}, + }}) + } + files := make([]agentconfig.PolicyFileReport, 20000) // ~3 MiB of file list + for i := range files { + files[i] = agentconfig.PolicyFileReport{Path: fmt.Sprintf("policies/m%05d.rego", i), SHA256: strings.Repeat("a", 64), Package: "compliance_framework.m"} + } + bundles := func() []agentconfig.PolicyBundleReport { + return []agentconfig.PolicyBundleReport{{Source: "ghcr.io/x/policies:v1", Digest: "sha256:t", ArtifactDigest: "sha256:a", Files: slices.Clone(files)}} + } + + // Dropping the file lists is enough: base is kept. + report := agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(1 << 19), Effective: config(1 << 19), PolicyBundles: bundles()} body, _, err := fitReport(&report, false) if err != nil { t.Fatal(err) } - if !report.Truncated || len(body) > agentconfig.MaxReportBytes { - t.Fatalf("expected a truncated report under the limit, got truncated=%v size=%d", report.Truncated, len(body)) + if !report.Truncated || len(body) > reportTargetBytes { + t.Fatalf("expected a truncated report under the target, got truncated=%v size=%d", report.Truncated, len(body)) + } + if len(report.PolicyBundles[0].Files) != 0 || report.PolicyBundles[0].ArtifactDigest != "sha256:a" || string(report.Base) == "{}" { + t.Fatalf("expected the file lists dropped and base kept: %+v", report.PolicyBundles[0]) + } + + // Then base is dropped. + report = agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(2 << 20), Effective: config(2 << 20), PolicyBundles: bundles()} + body, _, err = fitReport(&report, false) + if err != nil { + t.Fatal(err) } - if !strings.Contains(string(report.Effective), `"sha256:`) { - t.Fatalf("expected modules to be replaced by digests") + 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)) } } @@ -567,7 +580,6 @@ func TestApply_ClassifyGate(t *testing.T) { {"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"}, - {"R28 mixed-case bundle name", "apply_all", "", `{"policy_bundles":{"MyBundle":{"modules":{"a.rego":"package compliance_framework.a"}}}}`, "rejected", "invalid-config"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/report.go b/cmd/report.go index 54d6b9f..d627de0 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -6,9 +6,11 @@ import ( "encoding/hex" "encoding/json" "errors" + "maps" "net/http" "os" "runtime/debug" + "slices" "strings" "time" @@ -36,6 +38,32 @@ func SetAgentVersion(v string) { 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 @@ -158,15 +186,14 @@ func truncateString(s string, n int) string { } // fitReport encodes the report, shrinking it to reportTargetBytes when needed (or always when -// force is set, for a resend after a 413): first every policy bundle module in base and -// effective becomes "sha256:", then the policy bundle file lists are dropped, then base -// is dropped. Any step sets Truncated. It returns the body and its fingerprint. +// force is set, for a resend after a 413): first the policy bundle file lists are dropped, then +// base is dropped. Any step 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){hashReportModules, dropReportFileLists, dropReportBase} + steps := []func(*agentconfig.Report){dropReportFileLists, dropReportBase} for _, step := range steps { if !force && len(body) <= reportTargetBytes { break @@ -181,45 +208,9 @@ func fitReport(report *agentconfig.Report, force bool) ([]byte, string, error) { return body, hex.EncodeToString(sum[:]), nil } -func hashReportModules(report *agentconfig.Report) { - report.Base = hashDocModules(report.Base) - report.Effective = hashDocModules(report.Effective) -} - -// hashDocModules replaces policy_bundles.*.modules.* values with "sha256:". -func hashDocModules(doc json.RawMessage) json.RawMessage { - var obj map[string]any - dec := json.NewDecoder(strings.NewReader(string(doc))) - dec.UseNumber() - if err := dec.Decode(&obj); err != nil { - return doc - } - bundles, _ := obj["policy_bundles"].(map[string]any) - for _, raw := range bundles { - b, _ := raw.(map[string]any) - modules, _ := b["modules"].(map[string]any) - for p, src := range modules { - if s, ok := src.(string); ok { - sum := sha256.Sum256([]byte(s)) - modules[p] = "sha256:" + hex.EncodeToString(sum[:]) - } - } - } - out, err := json.Marshal(obj) - if err != nil { - return doc - } - return out -} - func dropReportFileLists(report *agentconfig.Report) { for i := range report.PolicyBundles { report.PolicyBundles[i].Files = []agentconfig.PolicyFileReport{} - if report.PolicyBundles[i].Extends != nil { - ext := *report.PolicyBundles[i].Extends - ext.Files = []agentconfig.PolicyFileReport{} - report.PolicyBundles[i].Extends = &ext - } } } diff --git a/cmd/shadow.go b/cmd/shadow.go deleted file mode 100644 index dc2a5d7..0000000 --- a/cmd/shadow.go +++ /dev/null @@ -1,296 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "maps" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/policyview" - "github.com/compliance-framework/api/pkg/agentconfig" -) - -// Path shadowing (R83). Plugins seed evidence UUIDs from the policy path string they -// receive, so an inline bundle that extends a source is given to plugins at the source's own -// path, and each such plugin runs in a view (internal/policyview) where that path resolves to -// the bundle's tree: inherited and overridden modules keep the vendor's streams with any -// plugin build. -// -// A bundle is shadowed when its extends source's plugin path is shadowable (relative, ending -// in policies/: every OCI source) and every enabled plugin that uses it can be given a view: -// it does not also load the source or another bundle on the same path, and its other -// relative paths still resolve in the view. Otherwise plugins receive the bundle at its own -// path under inlineLinksDir and its modules start path-based streams (R88; -// unshadowedWarnings says so per plugin). - -// shadowPlan is what prepareInline decided about shadowing, with the policy paths each -// plugin receives for its non-inline entries (resolved once, reused for the views). -type shadowPlan struct { - shadow map[string]bool // bundle name -> shadowed - // plugins are the enabled, not skipped plugins that use an inline bundle. - plugins map[string]shadowPlugin - // reasons say why a candidate bundle was not shadowed (logged once). - reasons map[string]string -} - -type shadowPlugin struct { - bundles []string // inline bundles it uses, in order, deduplicated - others []string // the paths it receives for every non-inline entry - sources map[string]bool -} - -// viewsRoot is where the plugin views live, under the state directory. -func (rc *reconciler) viewsRoot() string { - return filepath.Join(rc.store.Dir(), "views") -} - -// planShadowing decides which of the bundles in refs are shadowed. -func (rc *reconciler) planShadowing(ctx context.Context, resolved agentconfig.Config, skip map[string]string, refs map[string]bool) shadowPlan { - plan := shadowPlan{shadow: map[string]bool{}, plugins: map[string]shadowPlugin{}, reasons: map[string]string{}} - resolve := rc.boundedResolver() - unavailable := "" - switch { - case resolve == nil: - unavailable = "no policy resolver" - case rc.store == nil || !rc.store.Writable(): - unavailable = "plugin views need a writable state directory" - case !inlinepolicy.SymlinksSupported(rc.viewsRoot()): - unavailable = "plugin views need symlinks, which the file system does not support" - } - if unavailable != "" { - for name := range refs { - if b := resolved.PolicyBundles[name]; b != nil && b.Extends != nil { - plan.reasons[name] = unavailable - } - } - return plan - } - extendsPath := map[string]string{} - for _, name := range slices.Sorted(maps.Keys(refs)) { - b := resolved.PolicyBundles[name] - if b == nil || b.Extends == nil { - continue - } - dir, err := resolve(ctx, *b.Extends) - if err != nil { - continue // Materialize reports it - } - if err := policyview.Shadowable(dir); err != nil { - plan.reasons[name] = err.Error() - continue - } - extendsPath[name] = dir - plan.shadow[name] = true - } - - for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { - p := resolved.Plugins[pluginName] - if p == nil || !p.IsEnabled() { - continue - } - if _, skipped := skip[pluginName]; skipped { - continue - } - sp := shadowPlugin{sources: map[string]bool{}} - seen := map[string]bool{} - for _, e := range p.Policies { - entry := string(e) - if seen[entry] { - continue - } - seen[entry] = true - if name, ok := agentconfig.InlineBundleName(e); ok { - sp.bundles = append(sp.bundles, name) - continue - } - dir, err := resolve(ctx, entry) - if err != nil { - continue // prefetch reports it - } - sp.others = append(sp.others, dir) - sp.sources[filepath.Clean(dir)] = true - } - if len(sp.bundles) > 0 { - plan.plugins[pluginName] = sp - } - } - if len(plan.shadow) == 0 { - return plan - } - - // Drop candidates until every plugin can be given a view. - drop := func(name, why string) bool { - if !plan.shadow[name] { - return false - } - delete(plan.shadow, name) - plan.reasons[name] = why - return true - } - for changed := true; changed; { - changed = false - for _, pluginName := range slices.Sorted(maps.Keys(plan.plugins)) { - sp := plan.plugins[pluginName] - var shadowed, others []string - byPath := map[string][]string{} - for _, name := range sp.bundles { - if !plan.shadow[name] { - others = appendPath(others, rc.fallbackInlinePath(name)) - continue - } - path := extendsPath[name] - if sp.sources[filepath.Clean(path)] { - changed = drop(name, fmt.Sprintf("plugin %s also loads %s (%s) itself", pluginName, *resolved.PolicyBundles[name].Extends, path)) || changed - others = appendPath(others, rc.fallbackInlinePath(name)) - continue - } - byPath[filepath.Clean(path)] = append(byPath[filepath.Clean(path)], name) - shadowed = append(shadowed, path) - } - for _, names := range byPath { - if len(names) > 1 { - for _, name := range names { - changed = drop(name, fmt.Sprintf("plugin %s uses more than one bundle that extends %s", pluginName, extendsPath[name])) || changed - } - } - } - if changed { - break // recompute with the new decisions - } - if _, err := policyview.Plan(shadowed, append(others, sp.others...)); err != nil { - for _, name := range sp.bundles { - changed = drop(name, fmt.Sprintf("plugin %s cannot be given a view: %v", pluginName, err)) || changed - } - if changed { - break - } - } - } - } - for _, name := range slices.Sorted(maps.Keys(plan.reasons)) { - if rc.logOnce("shadow\x00" + name + "\x00" + plan.reasons[name]) { - rc.logger.Info("Inline bundle is not shadowed; plugins receive it at its own path, so its modules start new evidence streams", "bundle", name, "reason", plan.reasons[name]) - } - } - return plan -} - -// unshadowedWarnings warns, once per enabled plugin and inline bundle it uses, about an -// extends bundle that is not shadowed (R88): the plugin receives it at its own path, so the -// modules that would keep the vendor's evidence streams at the source's path -// (inlinepolicy.UnshadowedForks) start new ones. The reason is planShadowing's. -func unshadowedWarnings(resolved agentconfig.Config, skip map[string]string, plan shadowPlan, materialized map[string]*inlinepolicy.Materialized) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - for _, pluginName := range slices.Sorted(maps.Keys(resolved.Plugins)) { - p := resolved.Plugins[pluginName] - if p == nil || !p.IsEnabled() { - continue - } - if _, skipped := skip[pluginName]; skipped { - continue - } - seen := map[string]bool{} - for _, e := range p.Policies { - name, ok := agentconfig.InlineBundleName(e) - if !ok || seen[name] || materialized[name] == nil { - continue - } - seen[name] = true - m := materialized[name] - forks := inlinepolicy.UnshadowedForks(m) - if len(forks) == 0 { - continue - } - why := plan.reasons[name] - if why == "" { - why = fmt.Sprintf("%s cannot be shadowed", m.ExtendsDir) - } - listed := forks - if len(listed) > 10 { - listed = append(slices.Clip(listed[:10]), fmt.Sprintf("and %d more", len(forks)-10)) - } - out = append(out, agentconfig.PolicyError{Bundle: name, Severity: agentconfig.SeverityWarning, Code: inlinepolicy.CodePolicyStreamForked, - Message: fmt.Sprintf("plugin %s receives bundle %s at %s, not at the path of %s (%s), so %d of its modules (%s) record their evidence in new streams instead of the vendor's", - pluginName, name, m.Path, m.Extends.Source, why, len(forks), strings.Join(listed, ", "))}) - } - } - return out -} - -// fallbackInlinePath is the path plugins receive for an inline bundle that is not shadowed, -// or "" (an absolute tree directory, unaffected by views) without symlinks. -func (rc *reconciler) fallbackInlinePath(name string) string { - l := rc.inlineLayout() - if !inlinepolicy.SymlinksSupported(l.Links) { - return "" - } - return filepath.Join(l.Links, name, policyview.TreeDir) -} - -func appendPath(paths []string, p string) []string { - if p == "" { - return paths - } - return append(paths, p) -} - -// buildViews returns the view of every plugin that receives a shadowed bundle. -func (rc *reconciler) buildViews(plan shadowPlan, materialized map[string]*inlinepolicy.Materialized) (map[string]*policyview.View, error) { - var views map[string]*policyview.View - base, err := os.Getwd() - if err != nil { - return nil, err - } - root, err := filepath.Abs(rc.viewsRoot()) - if err != nil { - return nil, err - } - for _, pluginName := range slices.Sorted(maps.Keys(plan.plugins)) { - sp := plan.plugins[pluginName] - var shadowed, others []string - targets := map[string]string{} // plugin path -> the directory holding the tree - for _, name := range sp.bundles { - m := materialized[name] - if m == nil { - continue - } - if !m.Shadowed { - others = append(others, m.Path) - continue - } - dir, err := filepath.Abs(filepath.Dir(m.Dir)) - if err != nil { - return nil, err - } - shadowed = append(shadowed, m.Path) - targets[m.Path] = dir - } - if len(shadowed) == 0 { - continue - } - links, err := policyview.Plan(shadowed, append(others, sp.others...)) - if err != nil { - // planShadowing checked the same paths. - return nil, fmt.Errorf("plugin %s: %w", pluginName, err) - } - viewLinks := map[string]string{} - for path, link := range links { - viewLinks[link] = targets[path] - } - if views == nil { - views = map[string]*policyview.View{} - } - views[pluginName] = &policyview.View{ - Dir: policyview.DirFor(root, pluginName, base, viewLinks), - Base: base, - Links: viewLinks, - // Plugin-owned entries in the view (rule 1) are warnings, once per view and name. - Warn: rc.logger.Warn, - } - } - return views, nil -} diff --git a/cmd/shadow_test.go b/cmd/shadow_test.go deleted file mode 100644 index b3997cd..0000000 --- a/cmd/shadow_test.go +++ /dev/null @@ -1,617 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/compliance-framework/agent/internal/inlinepolicy" - "github.com/compliance-framework/agent/internal/policyview" - policy_manager "github.com/compliance-framework/agent/policy-manager" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/hashicorp/go-hclog" -) - -// Path shadowing: a plugin receives an inline bundle that extends a relative (OCI) source at -// the source's own path, and runs in a view where that path resolves to the bundle's tree. - -const ( - shadowSource = "ghcr.io/vendor/policies:v1" - // Where the agent extracts the OCI source: relative to its working directory. - shadowExtracted = ".compliance-framework/policies/vendor/policies/v1/policies" - // A plugin built on agent v0.1.9 (plugin-local-ssh v0.2.0): no set-form violations. - oldLib = "v0.1.9-0.20250708121809-c5059c3efac8" -) - -var shadowVendor = map[string]string{ - "banner.rego": "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation[{\"id\": \"b\"}] if not input.banner\n", - "keys.rego": "package compliance_framework.keys\n\nimport rego.v1\n\ntitle := \"Keys\"\n\nviolation[{\"id\": \"k\"}] if input.password\n", - "keys_test.rego": "package compliance_framework.keys_test\n\nimport rego.v1\n\ntest_ok if true\n", - "lib/helpers.rego": "package ccf_libs.helpers\n\nimport rego.v1\n\nyes := true\n", -} - -const shadowConfig = ` -daemon: true -api: - url: http://api.test - auth: - client_id: 123e4567-e89b-12d3-a456-426614174000 - client_secret: s3cret -remote_config: - mode: apply_safe -plugins: - ssh: - source: ghcr.io/compliance-framework/plugin-ssh:v1 - policies: ["inline:ssh"] -policy_bundles: - ssh: - extends: ghcr.io/vendor/policies:v1 - modules: - keys.rego: | - package compliance_framework.keys - - import rego.v1 - - title := "Keys (tuned)" - - violation[{"id": "k2"}] if input.password - added.rego: | - package compliance_framework.added - - import rego.v1 - - title := "Added" - - violation[{"id": "a"}] if input.password -` - -// shadowHarness changes to a new working directory holding the extracted vendor source and -// returns a harness on config whose policy sources resolve like the agent's (relative paths). -func shadowHarness(t *testing.T, config string) *remoteHarness { - t.Helper() - t.Chdir(t.TempDir()) - skipWithoutSymlinksHere(t) - for p, src := range shadowVendor { - dst := filepath.Join(shadowExtracted, filepath.FromSlash(p)) - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dst, []byte(src), 0o644); err != nil { - t.Fatal(err) - } - } - h := newRemoteHarness(t, config) - h.rc.inlineLinks = "" // the agent's default, relative to the working directory - h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { - if source == shadowSource { - return shadowExtracted, nil - } - return "", errors.New("unknown source " + source) - } - return h -} - -func skipWithoutSymlinksHere(t *testing.T) { - t.Helper() - if err := os.Symlink(".", "probe"); err != nil { - t.Skip("symlinks are not supported here") - } - _ = os.Remove("probe") -} - -type shadowEvidence struct { - uuid, title string - labels map[string]string -} - -// evaluateIn evaluates policyPath with the working directory dir, the way the ssh plugin -// does (labels with the literal _policy_path), and returns the evidence by package. -func evaluateIn(t *testing.T, dir, policyPath string) map[string]shadowEvidence { - t.Helper() - back, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - if err := os.Chdir(dir); err != nil { - t.Fatal(err) - } - defer func() { _ = os.Chdir(back) }() - labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-1", "_policy_path": policyPath} - evidence, err := policy_manager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). - GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) - if err != nil { - t.Fatal(err) - } - out := map[string]shadowEvidence{} - for _, e := range evidence { - out[e.Labels["_policy"]] = shadowEvidence{uuid: e.UUID, title: e.Title, labels: e.Labels} - } - return out -} - -func TestShadow_PluginReceivesTheVendorPathInItsView(t *testing.T) { - h := shadowHarness(t, shadowConfig) - withPluginLib(h, oldLib) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if r.Status != agentconfig.StatusApplied && r.Status != agentconfig.StatusNotApplicable { - t.Fatalf("expected the file bundle to load, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - - // The plugin receives the vendor's exact path string. - if got := active.runtime.inlinePolicyDirs["inline:ssh"]; got != shadowExtracted { - t.Fatalf("plugins receive %q, want the extends source's path %q", got, shadowExtracted) - } - view := active.runtime.pluginViews["ssh"] - if view == nil { - t.Fatal("the plugin has no view") - } - workDir, err := active.runtime.pluginWorkDir("ssh") - if err != nil || workDir != view.Dir { - t.Fatalf("plugin work dir = %q, %v; want the view %q", workDir, err, view.Dir) - } - - // Inside the view the path is the bundle's tree; outside it, still the vendor's. - tree := active.runtime.inlineTrees["inline:ssh"] - inView, err := filepath.EvalSymlinks(view.Resolve(shadowExtracted)) - if err != nil { - t.Fatal(err) - } - wantTree, _ := filepath.EvalSymlinks(tree) - if inView != wantTree { - t.Fatalf("in the view %s resolves to %s, want the bundle's tree %s", shadowExtracted, inView, wantTree) - } - if raw, err := os.ReadFile(filepath.Join(shadowExtracted, "keys.rego")); err != nil || string(raw) != shadowVendor["keys.rego"] { - t.Fatalf("the agent's own view of the vendor tree must be untouched: %q %v", raw, err) - } - // The path string carries the identity: inherited modules are the vendor's bytes. - raw, err := os.ReadFile(filepath.Join(tree, "banner.rego")) - if err != nil || string(raw) != shadowVendor["banner.rego"] { - t.Fatalf("an inherited module must be the vendor's bytes, got %q %v", raw, err) - } - - // Report: an old library is fine, and there are no stream warnings. - for _, code := range []string{inlinepolicy.CodePolicyStreamForked, agentconfig.PolicyCodeDuplicatePolicyIdentity} { - if got := policyErrorsWithCode(r, code); len(got) != 0 { - t.Fatalf("unexpected %s: %+v", code, got) - } - } - if len(r.Plugins) != 1 || r.Plugins[0].LibVersion != oldLib { - t.Fatalf("plugins report = %+v", r.Plugins) - } - - // Evidence: inherited and overridden modules keep the vendor UUIDs, added ones get new - // streams, and the modules really load from the bundle (the tuned title). - base, _ := os.Getwd() - vendor := evaluateIn(t, base, shadowExtracted) - got := evaluateIn(t, view.Dir, shadowExtracted) - if len(got) != 3 { - t.Fatalf("expected banner, keys and added evidence in the view, got %+v", got) - } - for _, pkg := range []string{"compliance_framework.banner", "compliance_framework.keys"} { - if got[pkg].uuid == "" || got[pkg].uuid != vendor[pkg].uuid { - t.Fatalf("%s: UUID %s, want the vendor's %s", pkg, got[pkg].uuid, vendor[pkg].uuid) - } - if got[pkg].labels["_policy_path"] != shadowExtracted { - t.Fatalf("%s: _policy_path = %q", pkg, got[pkg].labels["_policy_path"]) - } - } - if got["compliance_framework.keys"].title != "Keys (tuned)" { - t.Fatalf("the override must be evaluated, got title %q", got["compliance_framework.keys"].title) - } - added := got["compliance_framework.added"].uuid - if added == "" { - t.Fatal("the added module produced no evidence") - } - for _, e := range vendor { - if e.uuid == added { - t.Fatal("an added module must have its own stream") - } - } -} - -// TestShadow_NewRevisionIsANewView: editing the bundle gives the plugin a new view (no swap -// under a running plugin), the same path, and the same streams; GC removes the old view -// once no candidate uses it. -func TestShadow_NewRevisionIsANewView(t *testing.T) { - h := shadowHarness(t, shadowConfig) - withPluginLib(h, oldLib) - first := mustStartup(t, h.rc) - firstView := first.runtime.pluginViews["ssh"].Dir - base, _ := os.Getwd() - before := evaluateIn(t, firstView, shadowExtracted) - - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"added.rego":"package compliance_framework.added\n\nimport rego.v1\n\ntitle := \"Added v2\"\n\nviolation[{\"id\": \"a\"}] if input.password\n"}}}}`) - resolve := h.rc.resolvePolicy - h.rc = h.newReconciler() // a restart picks the revision up - h.rc.inlineLinks = "" - h.rc.resolvePolicy = resolve - withPluginLib(h, oldLib) - second := mustStartup(t, h.rc) - if second.overlay == nil { - r := h.remote.lastReport(t) - t.Fatalf("the edit must apply: %s/%s %v %+v", r.Status, r.Reason, derefString(r.Error), r.PolicyErrors) - } - secondView := second.runtime.pluginViews["ssh"].Dir - if secondView == firstView { - t.Fatal("a new tree must be a new view") - } - if second.runtime.inlinePolicyDirs["inline:ssh"] != shadowExtracted { - t.Fatal("the plugin path must not change") - } - after := evaluateIn(t, secondView, shadowExtracted) - for pkg, e := range before { - if after[pkg].uuid != e.uuid { - t.Fatalf("%s changed stream across revisions", pkg) - } - } - if after["compliance_framework.added"].title != "Added v2" { - t.Fatalf("the new view must hold the new tree, got %q", after["compliance_framework.added"].title) - } - - h.rc.mu.Lock() - h.rc.active, h.rc.pending, h.rc.starting, h.rc.fallback = second, nil, nil, nil - h.rc.mu.Unlock() - h.rc.gcInline() - if _, err := os.Lstat(firstView); !os.IsNotExist(err) { - t.Fatalf("the old view must be collected: %v", err) - } - if _, err := os.Stat(filepath.Join(secondView, shadowExtracted, "keys.rego")); err != nil { - t.Fatalf("the active view must stay: %v", err) - } - // Removing a view never follows its links. - if _, err := os.Stat(filepath.Join(base, shadowExtracted, "keys.rego")); err != nil { - t.Fatalf("GC must not touch the vendor tree: %v", err) - } -} - -// TestShadow_PluginAlsoLoadingTheSourceFallsBack: a plugin that loads the source and the -// bundle together cannot be given a view; plugins receive the bundle at its own path under -// _inline, its inherited modules start new streams (policy-stream-forked), and the duplicate -// is reported (R75): a warning from the file, an error when the overlay introduces it. -func TestShadow_PluginAlsoLoadingTheSourceFallsBack(t *testing.T) { - t.Run("file", func(t *testing.T) { - h := shadowHarness(t, strings.Replace(shadowConfig, `policies: ["inline:ssh"]`, `policies: ["inline:ssh", "ghcr.io/vendor/policies:v1"]`, 1)) - withPluginLib(h, "v0.9.0") - active := mustStartup(t, h.rc) - if got := active.runtime.inlinePolicyDirs["inline:ssh"]; filepath.ToSlash(got) != ".compliance-framework/policies/_inline/ssh/policies" { - t.Fatalf("plugins receive %q, want the bundle's own path", got) - } - if active.runtime.pluginViews["ssh"] != nil { - t.Fatal("no view without a shadowed bundle") - } - raw, _ := os.ReadFile(filepath.Join(active.runtime.inlineTrees["inline:ssh"], "banner.rego")) - if string(raw) != shadowVendor["banner.rego"] { - t.Fatalf("an inherited module keeps the vendor bytes, got %q", raw) - } - forked := policyErrorsWithCode(h.remote.lastReport(t), inlinepolicy.CodePolicyStreamForked) - if len(forked) != 1 || forked[0].Path != "" || forked[0].Bundle != "ssh" || !strings.Contains(forked[0].Message, "plugin ssh receives bundle ssh") || - !strings.Contains(forked[0].Message, "also loads") || !strings.Contains(forked[0].Message, "2 of its modules (banner.rego, keys.rego)") { - t.Fatalf("expected a policy-stream-forked warning for the inherited module, got %+v", h.remote.lastReport(t).PolicyErrors) - } - dups := policyErrorsWithCode(h.remote.lastReport(t), agentconfig.PolicyCodeDuplicatePolicyIdentity) - if len(dups) == 0 || dups[0].Severity != agentconfig.SeverityWarning { - t.Fatalf("expected a duplicate-policy-identity warning, got %+v", h.remote.lastReport(t).PolicyErrors) - } - }) - t.Run("overlay", func(t *testing.T) { - h := shadowHarness(t, shadowConfig) - withPluginLib(h, "v0.9.0") - h.remote.publish(1, `{"plugins":{"ssh":{"policies":["inline:ssh","ghcr.io/vendor/policies:v1"]}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay != nil || r.Status != agentconfig.StatusRejected { - t.Fatalf("loading the source next to its bundle must be rejected, got %s/%s", r.Status, r.Reason) - } - if len(rejectionErrors(r, agentconfig.PolicyCodeDuplicatePolicyIdentity)) == 0 { - t.Fatalf("expected duplicate-policy-identity errors, got %+v", r.PolicyErrors) - } - if active.runtime.inlinePolicyDirs["inline:ssh"] != shadowExtracted { - t.Fatal("the running file configuration keeps its shadowed bundle") - } - }) -} - -// TestShadow_AbsoluteExtendsIsNotShadowed: a bundle extending an absolute path cannot be -// shadowed; an overlay editing it applies on an old plugin, no plugin gets a view, and each -// plugin that uses it is warned, with the reason, that its modules start new streams. -func TestShadow_AbsoluteExtendsIsNotShadowed(t *testing.T) { - h, vendor := newInlineHarnessWith(t, strings.Replace(inlineBaseConfig, ` policies: ["inline:ssh"]`, ` policies: ["inline:ssh"] - other: - source: ghcr.io/compliance-framework/plugin-other:v1 - policies: ["inline:ssh"]`, 1)) // the vendor is an absolute temp dir - withPluginLib(h, oldLib) - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"extra.rego":"package compliance_framework.extra\n\nimport rego.v1\n\ntitle := \"extra v2\"\n\nviolation[{\"id\": \"x\"}] if input.max > data.max\n"}}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay == nil || r.Status != agentconfig.StatusApplied { - t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - forked := policyErrorsWithCode(r, inlinepolicy.CodePolicyStreamForked) - if len(forked) != 2 { - t.Fatalf("expected one policy-stream-forked warning per plugin, got %+v", r.PolicyErrors) - } - for i, plugin := range []string{"other", "ssh"} { - if e := forked[i]; e.Severity != agentconfig.SeverityWarning || e.Bundle != "ssh" || e.Path != "" || - !strings.Contains(e.Message, "plugin "+plugin+" receives bundle ssh") || !strings.Contains(e.Message, vendor+" is absolute") || - !strings.Contains(e.Message, "(banner.rego)") { - t.Fatalf("warning %d = %+v", i, e) - } - } - if len(active.runtime.pluginViews) != 0 { - t.Fatal("an absolute extends path gets no view") - } -} - -// TestShadow_OldLibOverlay: an overlay that edits a shadowed bundle of a plugin built on -// agent v0.1.9 applies; a set-form violation in it is still rejected (old policy-managers -// panic on it). -func TestShadow_OldLibOverlay(t *testing.T) { - t.Run("object form applies", func(t *testing.T) { - h := shadowHarness(t, shadowConfig) - withPluginLib(h, oldLib) - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"new.rego":"package compliance_framework.new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n"}}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay == nil || r.Status != agentconfig.StatusApplied { - t.Fatalf("expected applied, got %s/%s %+v", r.Status, r.Reason, r.PolicyErrors) - } - if set := policyErrorsWithCode(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported); len(set) != 0 { - t.Fatalf("the object form works with every plugin, got %+v", set) - } - }) - t.Run("set form is rejected", func(t *testing.T) { - h := shadowHarness(t, shadowConfig) - withPluginLib(h, oldLib) - h.remote.publish(1, `{"policy_bundles":{"ssh":{"modules":{"new.rego":"package compliance_framework.new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation contains {\"id\": \"n\"} if input.password\n"}}}}`) - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if active.overlay != nil || r.Status != agentconfig.StatusRejected { - t.Fatalf("expected rejected, got %s/%s", r.Status, r.Reason) - } - set := rejectionErrors(r, agentconfig.PolicyCodePluginLibViolationSetUnsupported) - if len(set) != 1 || set[0].Path != "new.rego" { - t.Fatalf("expected the set-form error, got %+v", r.PolicyErrors) - } - }) -} - -// TestShadow_PluginOwnedViewEntries (rule 1): a plugin that creates a directory relative to -// its working directory creates it in its view. When the agent's working directory later -// gets the same name, the plugin keeps its own; neither activation nor the plugin's run -// fails, and the warning is logged once. A real entry at the shadow link itself is a -// conflict: the plugin's run fails with a clear error, activation only logs it, and the -// entry is left alone. -func TestShadow_PluginOwnedViewEntries(t *testing.T) { - h := shadowHarness(t, shadowConfig) - withPluginLib(h, oldLib) - var logs strings.Builder - h.rc.logger = hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Warn}) - active := mustStartup(t, h.rc) - if err := h.rc.activateInline(active); err != nil { - t.Fatal(err) - } - view := active.runtime.pluginViews["ssh"] - if view == nil { - t.Fatal("the plugin has no view") - } - - // cloud-custodian writes debug-standardized-payloads/ relative to its working directory. - owned := filepath.Join(view.Dir, "debug-standardized-payloads") - if err := os.MkdirAll(owned, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(owned, "payload.json"), []byte("plugin"), 0o644); err != nil { - t.Fatal(err) - } - // ... and later, so does something in the agent's working directory. - if err := os.MkdirAll("debug-standardized-payloads", 0o755); err != nil { - t.Fatal(err) - } - - for range 3 { - if err := h.rc.activateInline(active); err != nil { - t.Fatalf("activation must not fail on a plugin-owned entry: %v", err) - } - if dir, err := active.runtime.pluginWorkDir("ssh"); err != nil || dir != view.Dir { - t.Fatalf("the plugin's run must not fail on a plugin-owned entry: %q %v", dir, err) - } - } - if n := strings.Count(logs.String(), "path=debug-standardized-payloads"); n != 1 || strings.Count(logs.String(), "[WARN]") != 1 { - t.Fatalf("want one warning naming the entry, got %d:\n%s", n, logs.String()) - } - if raw, err := os.ReadFile(filepath.Join(owned, "payload.json")); err != nil || string(raw) != "plugin" { - t.Fatalf("the plugin's entry must be kept: %q %v", raw, err) - } - - // The shadow link is the agent's: a real entry there is a conflict, never removed. - link := filepath.Join(view.Dir, filepath.Dir(shadowExtracted)) - if err := os.Remove(link); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(link, "policies"), 0o755); err != nil { - t.Fatal(err) - } - var conflict *policyview.LinkConflictError - if err := h.rc.activateInline(active); err != nil { - t.Fatalf("a view's conflict must not fail activation, got %v", err) - } - if _, err := active.runtime.pluginWorkDir("ssh"); !errors.As(err, &conflict) { - t.Fatalf("the run must fail with a link conflict, got %v", err) - } - if info, err := os.Lstat(link); err != nil || !info.IsDir() { - t.Fatalf("the conflicting entry must be left alone: %v", err) - } -} - -// TestShadow_ViewConflictFailsOnlyThatPlugin (M1): a conflict in one plugin's view fails -// only that plugin's runs. Activation logs it and carries on, another plugin's view is -// still prepared, and a restart (the view is content-addressed, so the conflict persists) -// still starts the configuration instead of exiting. -func TestShadow_ViewConflictFailsOnlyThatPlugin(t *testing.T) { - config := strings.Replace(shadowConfig, ` policies: ["inline:ssh"]`, ` policies: ["inline:ssh"] - other: - source: ghcr.io/compliance-framework/plugin-other:v1 - policies: ["inline:ssh"]`, 1) - h := shadowHarness(t, config) - withPluginLib(h, oldLib) - active := mustStartup(t, h.rc) - ssh, other := active.runtime.pluginViews["ssh"], active.runtime.pluginViews["other"] - if ssh == nil || other == nil || ssh.Dir == other.Dir { - t.Fatalf("each plugin needs its own view: %+v", active.runtime.pluginViews) - } - - // The ssh plugin replaced its shadow link with a directory of its own. - link := filepath.Join(ssh.Dir, filepath.Dir(shadowExtracted)) - if err := os.Remove(link); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(link, "policies"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.RemoveAll(other.Dir); err != nil { // and the other view must be rebuilt - t.Fatal(err) - } - - var logs strings.Builder - h.rc.logger = hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Warn}) - if err := h.rc.activateInline(active); err != nil { - t.Fatalf("one plugin's view must not fail the configuration: %v", err) - } - if !strings.Contains(logs.String(), "plugin=ssh") { - t.Fatalf("the conflict must be logged with the plugin, got:\n%s", logs.String()) - } - if _, err := os.Stat(filepath.Join(other.Dir, shadowExtracted, "keys.rego")); err != nil { - t.Fatalf("the other plugin's view must be prepared: %v", err) - } - if dir, err := active.runtime.pluginWorkDir("other"); err != nil || dir != other.Dir { - t.Fatalf("the other plugin must run: %q %v", dir, err) - } - var conflict *policyview.LinkConflictError - if _, err := active.runtime.pluginWorkDir("ssh"); !errors.As(err, &conflict) { - t.Fatalf("the conflicting plugin's run must fail with the link conflict, got %v", err) - } - - // A restart finds the same view: the configuration still starts. - resolve := h.rc.resolvePolicy - h.rc = h.newReconciler() - h.rc.inlineLinks = "" - h.rc.resolvePolicy = resolve - withPluginLib(h, oldLib) - restarted := mustStartup(t, h.rc) - if restarted.runtime.pluginViews["ssh"].Dir != ssh.Dir { - t.Fatal("the view is content-addressed: a restart must reuse it") - } - if _, err := restarted.runtime.pluginWorkDir("ssh"); !errors.As(err, &conflict) { - t.Fatalf("the conflict persists for that plugin only, got %v", err) - } -} - -// TestShadow_PlanDrops: a bundle is not shadowed for a plugin that uses a second bundle on -// the same path, or whose other policy paths cannot be resolved in a view; plugins then -// receive it at its own path, and the reason is logged. -func TestShadow_PlanDrops(t *testing.T) { - const otherSource = "ghcr.io/vendor/other:v1" - for _, tc := range []struct { - name, from, to string - // otherPath is where otherSource is extracted ("" when unused). - otherPath, reason string - }{ - { - name: "two bundles on one path", - from: "policy_bundles:\n", to: "policy_bundles:\n ssh2:\n extends: ghcr.io/vendor/policies:v1\n", - reason: "uses more than one bundle that extends " + shadowExtracted, - }, - { - name: "a policy root in a view directory", - from: `policies: ["inline:ssh"]`, to: `policies: ["inline:ssh", "` + otherSource + `"]`, - otherPath: ".compliance-framework/policies/vendor/policies/other", - reason: "cannot be given a view", - }, - } { - t.Run(tc.name, func(t *testing.T) { - config := strings.Replace(shadowConfig, tc.from, tc.to, 1) - if tc.otherPath == "" { - config = strings.Replace(config, `policies: ["inline:ssh"]`, `policies: ["inline:ssh", "inline:ssh2"]`, 1) - } - h := shadowHarness(t, config) - if tc.otherPath != "" { - if err := os.MkdirAll(tc.otherPath, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(tc.otherPath, "other.rego"), []byte("package compliance_framework.other\n\nimport rego.v1\n\ntitle := \"Other\"\n"), 0o644); err != nil { - t.Fatal(err) - } - vendor := h.rc.resolvePolicy - h.rc.resolvePolicy = func(ctx context.Context, source string) (string, error) { - if source == otherSource { - return tc.otherPath, nil - } - return vendor(ctx, source) - } - } - withPluginLib(h, oldLib) - var logs strings.Builder - h.rc.logger = hclog.New(&hclog.LoggerOptions{Output: &logs, Level: hclog.Info}) - active := mustStartup(t, h.rc) - if got := active.runtime.inlinePolicyDirs["inline:ssh"]; filepath.ToSlash(got) != ".compliance-framework/policies/_inline/ssh/policies" { - t.Fatalf("plugins receive %q, want the bundle's own path", got) - } - if active.runtime.pluginViews["ssh"] != nil { - t.Fatal("no view without a shadowed bundle") - } - if !strings.Contains(logs.String(), "Inline bundle is not shadowed") || !strings.Contains(logs.String(), tc.reason) { - t.Fatalf("the reason must be logged (%q), got:\n%s", tc.reason, logs.String()) - } - forked := policyErrorsWithCode(h.remote.lastReport(t), inlinepolicy.CodePolicyStreamForked) - if len(forked) == 0 { - t.Fatalf("expected policy-stream-forked warnings, got %+v", h.remote.lastReport(t).PolicyErrors) - } - for _, e := range forked { - if e.Path != "" || !strings.Contains(e.Message, "plugin ssh receives bundle "+e.Bundle) || !strings.Contains(e.Message, tc.reason) { - t.Fatalf("each unshadowed bundle's warning names the plugin and the reason: %+v", e) - } - } - }) - } -} - -// TestPluginCommand: plugins start by their absolute binary path in their working directory -// (a view), without the agent's API credentials. -func TestPluginCommand(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("uses a shell script as the plugin") - } - wd := t.TempDir() - t.Chdir(wd) - if err := os.MkdirAll("bin", 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join("bin", "plugin"), []byte("#!/bin/sh\npwd -P\necho \"secret=$CCF_API_AUTH_CLIENT_SECRET\"\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("CCF_API_AUTH_CLIENT_SECRET", "s3cret") - view := t.TempDir() - - cmd := pluginCommand(filepath.Join("bin", "plugin"), view) - if !filepath.IsAbs(cmd.Path) || cmd.Dir != view { - t.Fatalf("path = %q, dir = %q; want an absolute binary path and the view", cmd.Path, cmd.Dir) - } - out, err := cmd.Output() - if err != nil { - t.Fatalf("a relative binary path must still start in the view: %v", err) - } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - want, _ := filepath.EvalSymlinks(view) - if len(lines) != 2 || lines[0] != want || lines[1] != "secret=" { - t.Fatalf("the plugin must run in the view without the agent's credentials, got %q (view %s)", out, want) - } - - if cmd := pluginCommand(filepath.Join("bin", "plugin"), ""); cmd.Dir != "" { - t.Fatalf("without a view the plugin runs in the agent's working directory, got %q", cmd.Dir) - } -} diff --git a/go.mod b/go.mod index 69f8551..9d71656 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,6 @@ require ( github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-plugin v1.7.0 github.com/open-policy-agent/opa v1.14.1 - github.com/pelletier/go-toml/v2 v2.3.1 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -76,6 +75,7 @@ require ( github.com/oklog/run v1.2.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect diff --git a/internal/agentstate/store.go b/internal/agentstate/store.go index b658389..6388aa3 100644 --- a/internal/agentstate/store.go +++ b/internal/agentstate/store.go @@ -1,5 +1,5 @@ // Package agentstate owns the agent's per-instance state directory: the stable instance ID -// (R31), the remote configuration cache (R7) and the materialized inline policy bundles. +// (R31) and the remote configuration cache (R7). // // Layout (the OCI download caches under .compliance-framework/{plugins,policies} are shared // and unchanged): @@ -7,7 +7,6 @@ // .compliance-framework/state// key = hex(sha256(abs config path))[:16]; dir 0700 // instance-id 0644, UUID + "\n" // remote-config.json 0600 -// inline/// materialized inline policy bundles // // The package is a leaf: it never imports cmd. package agentstate diff --git a/internal/inlinepolicy/activate_test.go b/internal/inlinepolicy/activate_test.go deleted file mode 100644 index 283d440..0000000 --- a/internal/inlinepolicy/activate_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package inlinepolicy - -import ( - "context" - "os" - "path/filepath" - "runtime" - "sync" - "sync/atomic" - "testing" - - policyManager "github.com/compliance-framework/agent/policy-manager" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/hashicorp/go-hclog" -) - -// testLayout is the layout of the tests: the trees under root/store and the stable links -// under root/links. -func testLayout(root string) Layout { - return Layout{Store: filepath.Join(root, "store"), Links: filepath.Join(root, "links")} -} - -func skipWithoutSymlinks(t *testing.T, root string) { - t.Helper() - if runtime.GOOS == "windows" || !symlinksSupported(root) { - t.Skip("symlinks are not available") - } -} - -const stablePkg = "package compliance_framework.stable\n\ntitle := \"stable\"\n\nviolation contains {\"id\": \"s\"} if input.bad\n" - -// evidenceOf evaluates the policy path the way a plugin does and returns, per package, the -// evidence UUID and the policy file policy-manager seeds it with. -func evidenceOf(t *testing.T, policyPath string) map[string][2]string { - t.Helper() - pm := policyManager.New(context.Background(), hclog.NewNullLogger(), policyPath, nil) - results, err := pm.Execute(context.Background(), map[string]any{"bad": true}) - if err != nil { - t.Fatal(err) - } - evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), map[string]string{"plugin": "ssh"}, nil, nil, nil, nil, nil, nil). - GenerateResults(context.Background(), policyPath, map[string]any{"bad": true}) - if err != nil { - t.Fatal(err) - } - if len(evidence) != len(results) { - t.Fatalf("expected one evidence per result: %d vs %d", len(evidence), len(results)) - } - out := map[string][2]string{} - for _, r := range results { - pkg := r.Policy.Package.PurePackage() - for _, e := range evidence { - if e.Labels["_policy"] == pkg { - out[pkg] = [2]string{e.UUID, r.Policy.File} - } - } - } - return out -} - -// TestActivate_EvidenceIdentityIsStable_R67: two inline revisions that only change another -// package give the unchanged package the same policy_file, so the same evidence UUID. -func TestActivate_EvidenceIdentityIsStable_R67(t *testing.T) { - root := t.TempDir() - skipWithoutSymlinks(t, root) - revision := func(other string) *Materialized { - m, err := Materialize(context.Background(), testLayout(root), "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ - "stable.rego": stablePkg, - "other.rego": "package compliance_framework.other\n\ntitle := \"" + other + "\"\n", - }}, nil) - if err != nil { - t.Fatal(err) - } - if err := Activate(testLayout(root), "ssh", m.Dir); err != nil { - t.Fatal(err) - } - return m - } - - first := revision("v1") - before := evidenceOf(t, first.Path) - second := revision("v2") - after := evidenceOf(t, second.Path) - - if first.Dir == second.Dir || first.Path != second.Path { - t.Fatalf("the trees must differ and the stable path must not: %s %s / %s %s", first.Dir, second.Dir, first.Path, second.Path) - } - if want := filepath.Join(root, "links", "ssh", "policies"); first.Path != want { - t.Fatalf("stable path = %s, want %s", first.Path, want) - } - const stable, other = "compliance_framework.stable", "compliance_framework.other" - if before[stable] != after[stable] || before[stable][0] == "" { - t.Fatalf("an unchanged package must keep its evidence UUID and policy_file: %v vs %v", before[stable], after[stable]) - } - if before[other][1] != after[other][1] || after[other][0] == "" { - t.Fatalf("policy_file must not depend on the revision: %v vs %v", before[other], after[other]) - } - - // The stable path resolves to the active tree, so an artifact upload of it is the tree. - resolved, err := filepath.EvalSymlinks(second.Path) - if err != nil { - t.Fatal(err) - } - wantDir, _ := filepath.EvalSymlinks(second.Dir) - if resolved != wantDir { - t.Fatalf("stable path resolves to %s, want %s", resolved, wantDir) - } -} - -func TestActivate_RejectsForeignDirs(t *testing.T) { - root := t.TempDir() - skipWithoutSymlinks(t, root) - store := testLayout(root).Store - for _, dir := range []string{t.TempDir(), filepath.Join(store, "other", "abc", "policies"), filepath.Join(store, "ssh", "abc"), filepath.Join(store, "ssh", "abc", "bundle")} { - if err := Activate(testLayout(root), "ssh", dir); err == nil { - t.Fatalf("activating %s must fail", dir) - } - } - if err := Activate(testLayout(root), "ssh", filepath.Join(store, "ssh", "abc", "policies")); err == nil { - t.Fatal("activating a missing tree must fail") - } -} - -// TestGC_NeverRemovesCurrent: the tree current points to survives GC even when nothing keeps -// it and it is the oldest. -func TestGC_NeverRemovesCurrent(t *testing.T) { - root := t.TempDir() - skipWithoutSymlinks(t, root) - var dirs []string - for _, title := range []string{"a", "b", "c"} { - m, err := Materialize(context.Background(), testLayout(root), "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"" + title + "\"\n", - }}, nil) - if err != nil { - t.Fatal(err) - } - dirs = append(dirs, m.Dir) - } - if err := Activate(testLayout(root), "ssh", dirs[0]); err != nil { - t.Fatal(err) - } - // A crash between creating and renaming the temporary link leaves it behind. - stale := filepath.Join(root, "links", tmpPrefix+"ssh-stale") - if err := os.Symlink("x", stale); err != nil { - t.Fatal(err) - } - if err := GC(testLayout(root), nil, 0); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(dirs[0]); err != nil { - t.Fatalf("the active tree was removed: %v", err) - } - for _, d := range dirs[1:] { - if _, err := os.Stat(filepath.Dir(d)); !os.IsNotExist(err) { - t.Fatalf("%s should have been collected", d) - } - } - if _, err := os.Lstat(stale); !os.IsNotExist(err) { - t.Fatal("a stale temporary link must be removed") - } - if _, err := os.Stat(filepath.Join(root, "links", "ssh", "policies", "x.rego")); err != nil { - t.Fatalf("the stable path must still resolve: %v", err) - } -} - -// TestActivate_SwapIsAtomic: on Linux, readers resolving the stable path while it is swapped -// always find one of the two trees, never a missing path. (A reader walking the tree across a -// swap can still mix files of both trees, and on macOS APFS a lookup racing the rename can -// fail with EINVAL; the agent therefore swaps only between configuration runs, never while a -// plugin of the previous configuration runs.) -func TestActivate_SwapIsAtomic(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("rename(2) over a symlink is atomic for concurrent lookups on Linux only") - } - root := t.TempDir() - skipWithoutSymlinks(t, root) - var trees [2]*Materialized - for i, title := range []string{"a", "b"} { - m, err := Materialize(context.Background(), testLayout(root), "ssh", &agentconfig.PolicyBundle{Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"" + title + "\"\n", - }}, nil) - if err != nil { - t.Fatal(err) - } - trees[i] = m - } - if err := Activate(testLayout(root), "ssh", trees[0].Dir); err != nil { - t.Fatal(err) - } - want := map[string]bool{} - for _, m := range trees { - resolved, err := filepath.EvalSymlinks(m.Dir) - if err != nil { - t.Fatal(err) - } - want[resolved] = true - } - - var stop atomic.Bool - var failures atomic.Int32 - var wg sync.WaitGroup - for range 4 { - wg.Add(1) - go func() { - defer wg.Done() - for !stop.Load() { - resolved, err := filepath.EvalSymlinks(trees[0].Path) - if err != nil || !want[resolved] { - failures.Add(1) - } - if _, err := os.ReadFile(filepath.Join(trees[0].Path, "x.rego")); err != nil { - failures.Add(1) - } - } - }() - } - for i := range 500 { - if err := Activate(testLayout(root), "ssh", trees[(i+1)%2].Dir); err != nil { - t.Error(err) - break - } - } - stop.Store(true) - wg.Wait() - if n := failures.Load(); n != 0 { - t.Fatalf("%d reads did not find a complete tree during the swaps", n) - } -} diff --git a/internal/inlinepolicy/check.go b/internal/inlinepolicy/check.go deleted file mode 100644 index d6ff4a9..0000000 --- a/internal/inlinepolicy/check.go +++ /dev/null @@ -1,341 +0,0 @@ -package inlinepolicy - -import ( - "context" - "errors" - "fmt" - "maps" - "path/filepath" - "slices" - "strings" - "time" - - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/pkg/policyeval" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/loader" - "github.com/open-policy-agent/opa/v1/rego" - "github.com/open-policy-agent/opa/v1/storage/inmem" - "github.com/open-policy-agent/opa/v1/tester" -) - -// TestTimeout bounds the Rego tests of one (plugin, policy path) check. -var TestTimeout = 30 * time.Second - -// CheckInput is one plugin × materialized policy path. -type CheckInput struct { - Plugin, Bundle string - PolicyDir string - Authored map[string]bool - AuthoredTests []string - PolicyData map[string]any -} - -// Check compiles and tests one materialized bundle exactly the way a plugin will load it -// (R3, R21): one compile unit per policy path, through the same policyeval constructor and -// prepare path policy-manager uses. It returns errors (the revision is rejected) and warnings: -// -// 1. parity compile: every compile error is an error; -// 2. denied builtins (R19, R20): any policyeval.DeniedBuiltins ref reachable from an authored -// rule through the compiled rule graph — including `with f as http.send` — is an error; -// 3. tests: a failing authored _test.rego test is an error, a failing vendor test a warning. -// Tests never run when step 2 found a denied builtin, and they run sandboxed: a denied -// builtin can never execute on the agent host (D17, HLD §8); -// 4. the policy contract (R63, contract.go): statically on vendor-only packages, then, when -// nothing so far is an error, a sandboxed dry run on an empty input. -// -// A compile error in a vendor file whose package an authored module also defines carries a -// hint about the override that most likely caused it (R65). -// -// The parse-level checks (regocheck, including the static contract check of the authored -// modules) run once per bundle before materialization. -func Check(ctx context.Context, in CheckInput) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - add := func(severity string, loc *ast.Location, format string, args ...any) { - e := agentconfig.PolicyError{Bundle: in.Bundle, Message: fmt.Sprintf(format, args...), Severity: severity} - if in.Plugin != "" { - e.Message = fmt.Sprintf("plugin %s: %s", in.Plugin, e.Message) - } - if loc != nil { - e.Path = relPath(in.PolicyDir, loc.File) - e.Row, e.Col = loc.Row, loc.Col - } - out = append(out, e) - } - - // 1. Parity compile. - _, err := policyeval.NewFromBundlePath(in.PolicyDir, in.PolicyData, policyeval.Options{}). - PrepareForEval(ctx, rego.Query("data.compliance_framework"), rego.Package("compliance_framework")) - if err != nil { - hints := compileHints(in) - addCompileErrors(err, func(loc *ast.Location, msg string) { - add(agentconfig.SeverityError, loc, "%s", hints(loc, strings.ReplaceAll(msg, in.PolicyDir+string(filepath.Separator), ""))) - }) - agentconfig.SortPolicyErrors(out) - return out - } - - b, err := loader.NewFileLoader().WithRegoVersion(ast.RegoV1).AsBundle(in.PolicyDir) - if err != nil { - add(agentconfig.SeverityError, nil, "load bundle: %s", err.Error()) - return out - } - modules := make(map[string]*ast.Module, len(b.Modules)) - for _, mf := range b.Modules { - modules[mf.Path] = mf.Parsed - } - compiler := ast.NewCompiler() - if compiler.Compile(modules); compiler.Failed() { - hints := compileHints(in) - addCompileErrors(compiler.Errors, func(loc *ast.Location, msg string) { add(agentconfig.SeverityError, loc, "%s", hints(loc, msg)) }) - agentconfig.SortPolicyErrors(out) - return out - } - pkgs := packagesOf(modules, in.PolicyDir) - authoredPkgs := pkgs.authoredPackages(in.Authored) - - // 2. Transitive denied builtins. The revision is rejected, so the tests (which would - // execute the denied builtin) never run. - hits := deniedReachable(compiler, in.PolicyDir, in.Authored) - for _, h := range hits { - add(agentconfig.SeverityError, h.loc, "forbidden builtin %s is reachable from authored rule %s", h.name, h.from) - } - if len(hits) > 0 { - agentconfig.SortPolicyErrors(out) - return out - } - - // 3. Tests. - out = append(out, runTests(ctx, in, b.Data, modules)...) - - // 4. Policy contract. - static, vendorSeen := staticContract(in, modules, authoredPkgs) - out = append(out, static...) - if !agentconfig.HasPolicyErrors(out) { - out = append(out, dryRun(ctx, in, b, pkgs, authoredPkgs, vendorSeen)...) - } - agentconfig.SortPolicyErrors(out) - return out -} - -// compileHints returns a function that appends the R65 override hint to a compile error -// located in a vendor file. -func compileHints(in CheckInput) func(loc *ast.Location, msg string) string { - pkgs := treePackages{} - if files, _, err := readTree(in.PolicyDir); err == nil { - for _, f := range inventory(files) { - if f.Package != "" { - pkgs[f.Path] = f.Package - } - } - } - return func(loc *ast.Location, msg string) string { - if loc == nil { - return msg - } - if hint := overrideHint(relPath(in.PolicyDir, loc.File), pkgs, in.Authored); hint != "" { - return msg + " (hint: " + hint + ")" - } - return msg - } -} - -func addCompileErrors(err error, add func(loc *ast.Location, msg string)) { - var astErrs ast.Errors - switch e := err.(type) { - case ast.Errors: - astErrs = e - case *ast.Error: - astErrs = ast.Errors{e} - default: - var single *ast.Error - if errors.As(err, &astErrs) { - break - } - if errors.As(err, &single) { - astErrs = ast.Errors{single} - } - } - if len(astErrs) == 0 { - add(nil, err.Error()) - return - } - for _, e := range astErrs { - add(e.Location, e.Message) - } -} - -// relPath makes a module file path relative to the policy root (slash-separated). -func relPath(root, file string) string { - if file == "" { - return "" - } - if rel, err := filepath.Rel(root, file); err == nil && !strings.HasPrefix(rel, "..") { - return filepath.ToSlash(rel) - } - return filepath.ToSlash(file) -} - -type deniedHit struct { - name string - loc *ast.Location - from string -} - -// deniedReachable walks every rule of the authored modules and, through data refs, every rule -// they can reach, and reports each denied builtin ref (calls, `with ... as `, any -// position). -func deniedReachable(c *ast.Compiler, root string, authored map[string]bool) []deniedHit { - var hits []deniedHit - seenHit := map[string]bool{} - visited := map[*ast.Rule]bool{} - opts := ast.RulesOptions{IncludeHiddenModules: true} - - var visit func(rule *ast.Rule, from string) - visit = func(rule *ast.Rule, from string) { - if visited[rule] { - return - } - visited[rule] = true - ast.WalkRefs(rule, func(ref ast.Ref) bool { - if len(ref) == 0 { - return false - } - name := ref.String() - if slices.Contains(policyeval.DeniedBuiltins, name) { - loc := ref[0].Location - key := name - if loc != nil { - key = fmt.Sprintf("%s:%s:%d:%d", name, loc.File, loc.Row, loc.Col) - } - if !seenHit[key] { - seenHit[key] = true - hits = append(hits, deniedHit{name: name, loc: loc, from: from}) - } - return false - } - if ref.HasPrefix(ast.DefaultRootRef) { - for _, r := range c.GetRulesDynamicWithOpts(ref, opts) { - visit(r, from) - } - } - return false - }) - if rule.Else != nil { - visit(rule.Else, from) - } - } - - for _, path := range slices.Sorted(maps.Keys(c.Modules)) { - if !authored[relPath(root, path)] { - continue - } - mod := c.Modules[path] - for _, rule := range mod.Rules { - from := strings.TrimPrefix(rule.Ref().String(), "data.") - if mod.Package != nil { - from = strings.TrimPrefix(mod.Package.Path.String(), "data.") + "." + rule.Head.Ref().String() - } - visit(rule, from) - } - } - return hits -} - -// runTests runs the bundle's Rego tests against the bundle data merged with the plugin's -// policy_data. Authored test failures are errors, vendor test failures warnings (R21). -func runTests(ctx context.Context, in CheckInput, bundleData map[string]any, modules map[string]*ast.Module) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - testCtx, cancel := context.WithTimeout(ctx, TestTimeout) - defer cancel() - store := inmem.NewFromObject(policyeval.MergeData(bundleData, in.PolicyData)) - sandboxed, stubs, caps := sandboxTestModules(modules) - ch, err := tester.NewRunner(). - SetCompiler(ast.NewCompiler().WithCapabilities(caps)). - AddCustomBuiltins(stubs). - SetStore(store). - SetModules(sandboxed). - SetTimeout(TestTimeout). - RunTests(testCtx, nil) - if err != nil { - return []agentconfig.PolicyError{{Bundle: in.Bundle, Message: prefixPlugin(in.Plugin, "run tests: "+err.Error()), Severity: agentconfig.SeverityError}} - } - for res := range ch { - if res == nil || res.Pass() || res.Skip { - continue - } - e := agentconfig.PolicyError{Bundle: in.Bundle, Severity: agentconfig.SeverityWarning} - if res.Location != nil { - e.Path = relPath(in.PolicyDir, res.Location.File) - e.Row, e.Col = res.Location.Row, res.Location.Col - } - if in.Authored[e.Path] || slices.Contains(in.AuthoredTests, e.Path) { - e.Severity = agentconfig.SeverityError - } - msg := fmt.Sprintf("test %s.%s failed", strings.TrimPrefix(res.Package, "data."), res.Name) - if res.Error != nil { - msg = fmt.Sprintf("test %s.%s errored: %v", strings.TrimPrefix(res.Package, "data."), res.Name, res.Error) - } - e.Message = prefixPlugin(in.Plugin, msg) - out = append(out, e) - } - if testCtx.Err() != nil && ctx.Err() == nil { - out = append(out, agentconfig.PolicyError{Bundle: in.Bundle, Message: prefixPlugin(in.Plugin, fmt.Sprintf("tests timed out after %s", TestTimeout)), Severity: agentconfig.SeverityError}) - } - return out -} - -// deniedStubPrefix names the stand-ins for denied builtins in sandboxed test runs. -const deniedStubPrefix = "ccf_denied_builtin." - -// sandboxTestModules returns copies of modules in which every denied builtin ref (a call, -// `with ... as `, any position) is rewritten to a stub builtin that always errors, -// the stubs, and capabilities without the denied builtins (policyeval.SandboxCapabilities) -// plus the stubs. A denied builtin therefore can never execute during tests: whatever the -// rewrite misses fails to compile instead. Vendor modules that merely reference a denied -// builtin off the authored path still compile, and their tests fail (as warnings). -func sandboxTestModules(modules map[string]*ast.Module) (map[string]*ast.Module, []*tester.Builtin, *ast.Capabilities) { - caps := policyeval.SandboxCapabilities() - stubNames := map[string]ast.Ref{} - var stubs []*tester.Builtin - for _, name := range policyeval.DeniedBuiltins { - decl, ok := ast.BuiltinMap[name] - if !ok { - continue - } - stubName := deniedStubPrefix + strings.ReplaceAll(name, ".", "_") - stubDecl := &ast.Builtin{Name: stubName, Decl: decl.Decl} - caps.Builtins = append(caps.Builtins, stubDecl) - stubNames[name] = ast.MustParseRef(stubName) - denied := name - stubs = append(stubs, &tester.Builtin{ - Decl: stubDecl, - Func: rego.FunctionDyn(®o.Function{Name: stubName, Decl: decl.Decl}, func(rego.BuiltinContext, []*ast.Term) (*ast.Term, error) { - return nil, fmt.Errorf("%s is not available in inline policy tests", denied) - }), - }) - } - - out := make(map[string]*ast.Module, len(modules)) - for path, mod := range modules { - cp := mod.Copy() - res, err := ast.TransformRefs(cp, func(ref ast.Ref) (ast.Value, error) { - if stub, ok := stubNames[ref.String()]; ok { - return stub.Copy(), nil - } - return ref, nil - }) - if m, ok := res.(*ast.Module); ok && err == nil { - cp = m - } - out[path] = cp - } - return out, stubs, caps -} - -func prefixPlugin(plugin, msg string) string { - if plugin == "" { - return msg - } - return fmt.Sprintf("plugin %s: %s", plugin, msg) -} diff --git a/internal/inlinepolicy/contract.go b/internal/inlinepolicy/contract.go deleted file mode 100644 index c0df571..0000000 --- a/internal/inlinepolicy/contract.go +++ /dev/null @@ -1,337 +0,0 @@ -package inlinepolicy - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "regexp" - "slices" - "strings" - - policyManager "github.com/compliance-framework/agent/policy-manager" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/pkg/agentconfig/regocheck" - "github.com/compliance-framework/api/pkg/policyeval" - "github.com/hashicorp/go-hclog" - "github.com/open-policy-agent/opa/v1/ast" - "github.com/open-policy-agent/opa/v1/bundle" - "github.com/open-policy-agent/opa/v1/rego" - "github.com/open-policy-agent/opa/v1/topdown" -) - -// The policy contract checks (R63). The API's regocheck runs the static contract check on -// the authored modules of every bundle before materialization; here the agent adds what only -// it can see, on the materialized tree: -// -// - static: policyeval.CheckContract on the tree: every issue of the packages no authored -// module touches (vendor debt: warnings only, R21/R34), and duplicate non-test modules -// of authored packages; -// - dynamic: a sandboxed dry run on an empty input through policyeval's Execute and -// policy-manager's GetRiskTemplates, exactly the calls plugins make. Problems in a -// package that contains an authored module are errors (as the contract rates them); -// in vendor-only packages they are warnings. Evaluation conflicts on {} are warnings: -// the real input may never trigger them. - -// treePackages maps every .rego file of the tree (relative path) to its package, without -// "data.". -type treePackages map[string]string - -func packagesOf(modules map[string]*ast.Module, root string) treePackages { - out := treePackages{} - for path, mod := range modules { - if mod != nil && mod.Package != nil { - out[relPath(root, path)] = packageOf(mod) - } - } - return out -} - -// authoredPackages returns the packages that contain an authored non-test module. An -// authored test alone does not make a vendor package authored: adding a test must not turn -// the vendor's contract debt into errors. -func (p treePackages) authoredPackages(authored map[string]bool) map[string]bool { - out := map[string]bool{} - for file, pkg := range p { - if authored[file] && !policyeval.IsTestFile(file) { - out[pkg] = true - } - } - return out -} - -// filesOf returns the sorted files defining pkg, optionally only authored or only non-test ones. -func (p treePackages) filesOf(pkg string, keep func(file string) bool) []string { - var out []string - for file, fp := range p { - if fp == pkg && (keep == nil || keep(file)) { - out = append(out, file) - } - } - slices.Sort(out) - return out -} - -// overrideHint explains a compile error in a vendor file whose package an authored module -// also defines (R65): usually an override removed a rule the vendor's test still uses. -func overrideHint(file string, pkgs treePackages, authored map[string]bool) string { - pkg, ok := pkgs[file] - if !ok || file == "" || authored[file] { - return "" - } - overrides := pkgs.filesOf(pkg, func(f string) bool { return authored[f] }) - if len(overrides) == 0 { - return "" - } - kind := "vendor module" - if policyeval.IsTestFile(file) { - kind = "vendor test" - } - quoted := make([]string, len(overrides)) - for i, f := range overrides { - quoted[i] = "`" + f + "`" - } - return fmt.Sprintf("%s references rules removed by the override of %s; keep the rule or add `delete: [%s]`", kind, strings.Join(quoted, ", "), file) -} - -// staticContract runs the static contract check: every issue of the packages no authored -// module touches (vendor debt, warnings), and duplicate-package-module for the packages an -// authored module is in, which regocheck cannot see because it only has the authored modules -// (it checked the rest of the contract on them). It returns the issues and the (package, -// code) pairs it reported for vendor packages, so the dry run does not repeat them. -func staticContract(in CheckInput, modules map[string]*ast.Module, authoredPkgs map[string]bool) ([]agentconfig.PolicyError, map[[2]string]bool) { - vendor, authored := map[string]*ast.Module{}, map[string]*ast.Module{} - for path, mod := range modules { - if authoredPkgs[packageOf(mod)] { - authored[path] = mod - } else { - vendor[path] = mod - } - } - var out []agentconfig.PolicyError - add := func(issue policyeval.Issue, suffix string) { - e := regocheck.ToPolicyError(in.Bundle, issue) - e.Path = relPath(in.PolicyDir, issue.File) - e.Message = strings.ReplaceAll(e.Message, in.PolicyDir+string(filepath.Separator), "") + suffix - e.Severity = agentconfig.SeverityWarning - out = append(out, e) - } - seen := map[[2]string]bool{} - for _, issue := range policyeval.CheckContract(vendor) { - seen[[2]string{issue.Package, issue.Code}] = true - add(issue, " (vendor package: a warning only)") - } - for _, issue := range policyeval.CheckContract(authored) { - if issue.Code == agentconfig.PolicyCodeDuplicatePackageModule { - add(issue, "") - } - } - return out, seen -} - -// Codes of the dry-run problems that are not policyeval contract issues. -const ( - codeEvalError = "eval-error" - codeEvalConflict = "eval-conflict" - codeDryRunTimeout = "dry-run-timeout" -) - -// evalLocation matches the package and file policyeval.Execute names in its decode errors. -var evalLocation = regexp.MustCompile(` ?\(policy package "([^"]+)", file "([^"]*)"\)`) - -// dryRun evaluates the tree on an empty input the way a plugin does, sandboxed: denied -// builtins are rewritten to erroring stubs and the evaluator only has -// policyeval.SandboxCapabilities, so nothing reaches the network or the host. A package -// whose evaluation fails is reported and left out of the next attempt, so one broken -// package does not hide the others. -func dryRun(ctx context.Context, in CheckInput, b *bundle.Bundle, pkgs treePackages, authoredPkgs map[string]bool, vendorSeen map[[2]string]bool) []agentconfig.PolicyError { - ctx, cancel := context.WithTimeout(ctx, TestTimeout) - defer cancel() - - parsed := make(map[string]*ast.Module, len(b.Modules)) - for _, mf := range b.Modules { - parsed[mf.Path] = mf.Parsed - } - sandboxed, stubs, caps := sandboxTestModules(parsed) - - var out []agentconfig.PolicyError - seen := map[string]bool{} // package + code + message - riskReported := map[string]bool{} - add := func(pkg, file, code, severity, msg string) { - key := pkg + "\x00" + code + "\x00" + msg - if seen[key] { - return - } - seen[key] = true - if code == agentconfig.PolicyCodeInvalidRiskTemplate || strings.Contains(msg, "risk_templates") { - riskReported[pkg] = true - } - out = append(out, agentconfig.PolicyError{ - Bundle: in.Bundle, - Path: file, - Message: prefixPlugin(in.Plugin, "on an empty input: "+msg), - Severity: severity, - Code: code, - }) - } - severityFor := func(pkg string) string { - if authoredPkgs[pkg] { - return agentconfig.SeverityError - } - return agentconfig.SeverityWarning - } - - excluded := map[string]bool{} - evaluator := func() (*policyeval.Evaluator, bool) { - dry := &bundle.Bundle{Data: b.Data, Manifest: b.Manifest.Copy()} - dry.Manifest.Init() - policies := false - for _, mf := range b.Modules { - pkg := pkgs[relPath(in.PolicyDir, mf.Path)] - if excluded[pkg] { - continue - } - mf.Parsed = sandboxed[mf.Path] - dry.Modules = append(dry.Modules, mf) - if policyeval.IsPolicyPackage(pkg) && !policyeval.IsTestFile(mf.Path) { - policies = true - } - } - loaders := []func(*rego.Rego){rego.ParsedBundle("inline", dry)} - for _, s := range stubs { - loaders = append(loaders, s.Func) - } - return policyeval.NewWithLoaders(loaders, in.PolicyData, policyeval.Options{Capabilities: caps}), policies - } - - // failure reports err and returns the package to leave out, or "" to stop. - failure := func(err error, code string) string { - if ctx.Err() != nil { - if errors.Is(ctx.Err(), context.DeadlineExceeded) { - // Not attributable to authored Rego (vendor packages are evaluated too), so - // never a reason to reject. - add("", "", codeDryRunTimeout, agentconfig.SeverityWarning, fmt.Sprintf("the dry run timed out after %s; the policy contract was not fully checked", TestTimeout)) - } - return "" - } - pkg, file := locateEvalError(err, in.PolicyDir, pkgs) - severity := severityFor(pkg) - var te *topdown.Error - switch msg := err.Error(); { - case errors.As(err, &te) && te.Code == topdown.ConflictErr: - severity = agentconfig.SeverityWarning - code = codeEvalConflict - case strings.Contains(msg, "decode violation entry") || strings.Contains(msg, "unexpected violations type"): - code = agentconfig.PolicyCodeInvalidViolation - case strings.Contains(msg, "decode policy outputs") || strings.Contains(msg, "expected module outputs"): - code = agentconfig.PolicyCodeInvalidType - } - if pkg == "" { - // Not attributable to a package: never reject on it. - add("", file, code, agentconfig.SeverityWarning, "could not evaluate the bundle: "+err.Error()) - return "" - } - if severity == agentconfig.SeverityWarning && vendorSeen[[2]string{pkg, code}] { - return pkg - } - msg := evalLocation.ReplaceAllString(err.Error(), "") - add(pkg, file, code, severity, fmt.Sprintf("package %s: %s", pkg, strings.ReplaceAll(msg, in.PolicyDir+string(filepath.Separator), ""))) - return pkg - } - - var ev *policyeval.Evaluator - for range len(pkgs) + 1 { - var policies bool - if ev, policies = evaluator(); !policies { - return out - } - results, err := ev.Execute(ctx, map[string]any{}) - if err != nil { - pkg := failure(err, codeEvalError) - if pkg == "" || excluded[pkg] { - return out - } - excluded[pkg] = true - continue - } - for _, r := range results { - pkg := r.Policy.Package.PurePackage() - file := relPath(in.PolicyDir, r.Policy.File) - for _, issue := range r.Issues { - severity := issue.Severity - msg := issue.Message - if !authoredPkgs[pkg] { - if vendorSeen[[2]string{pkg, issue.Code}] { - continue - } - severity = agentconfig.SeverityWarning - } - if issue.Code == agentconfig.PolicyCodeMissingTitle && hasRule(parsed, pkg, "title") { - // The title depends on the input; the static check rates that a warning. - severity = agentconfig.SeverityWarning - } - add(pkg, file, issue.Code, severity, msg) - } - } - break - } - - // Risk templates, through the same call plugins make at Init. - for range len(pkgs) + 1 { - if ev == nil { - break - } - _, err := policyManager.NewWithEvaluator(hclog.NewNullLogger(), ev).GetRiskTemplates(ctx) - if err == nil { - break - } - var rte *policyManager.RiskTemplateError - if errors.As(err, &rte) && riskReported[rte.Package] { - // ValidateResult already reported this package's risk templates. - excluded[rte.Package] = true - } else { - pkg := failure(err, agentconfig.PolicyCodeInvalidRiskTemplate) - if pkg == "" || excluded[pkg] { - break - } - excluded[pkg] = true - } - var policies bool - if ev, policies = evaluator(); !policies { - break - } - } - return out -} - -// locateEvalError finds the package (without "data.") and file an evaluation error is about. -func locateEvalError(err error, root string, pkgs treePackages) (pkg, file string) { - var rte *policyManager.RiskTemplateError - if errors.As(err, &rte) { - return rte.Package, relPath(root, rte.File) - } - var te *topdown.Error - if errors.As(err, &te) && te.Location != nil && te.Location.File != "" { - file = relPath(root, te.Location.File) - return pkgs[file], file - } - if m := evalLocation.FindStringSubmatch(err.Error()); m != nil { - return strings.TrimPrefix(m[1], "data."), relPath(root, m[2]) - } - return "", "" -} - -// hasRule reports whether any module of pkg defines a rule named name. -func hasRule(modules map[string]*ast.Module, pkg, name string) bool { - for _, mod := range modules { - if packageOf(mod) != pkg { - continue - } - for _, rule := range mod.Rules { - if policyeval.RuleName(rule) == name { - return true - } - } - } - return false -} diff --git a/internal/inlinepolicy/contract_test.go b/internal/inlinepolicy/contract_test.go deleted file mode 100644 index befc33e..0000000 --- a/internal/inlinepolicy/contract_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package inlinepolicy - -import ( - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "github.com/compliance-framework/api/pkg/agentconfig" -) - -// The e2e repro of §13.1: the UI's Override replaced the vendor module with a skeleton, so the -// vendor test in the same package no longer compiles. -const ( - sshVendorModule = `package compliance_framework.ssh_deny_password_auth - -import rego.v1 - -title := "SSH password authentication is disabled" - -violation contains {"id": "password-auth", "title": "Password authentication is enabled"} if { - input.passwordauthentication == "yes" -} -` - sshVendorTest = `package compliance_framework.ssh_deny_password_auth - -import rego.v1 - -test_password_auth_denied if { - count(violation) == 1 with input as {"passwordauthentication": "yes"} -} -` - sshOverrideSkeleton = "package compliance_framework.ssh_deny_password_auth\n\nimport rego.v1\n" -) - -func TestCheck_OverrideBreaksVendorTest_R65(t *testing.T) { - vendor := map[string]string{ - "ssh/ssh_deny_password_auth.rego": sshVendorModule, - "ssh/ssh_deny_password_auth_test.rego": sshVendorTest, - } - - m := materialize(t, vendor, &agentconfig.PolicyBundle{ - Extends: strptr("ghcr.io/vendor/policies:v1"), - Modules: map[string]string{"ssh/ssh_deny_password_auth.rego": sshOverrideSkeleton}, - }) - errs := errorsOf(check(m, nil), agentconfig.SeverityError) - if len(errs) == 0 { - t.Fatal("a vendor test that no longer compiles must reject the revision") - } - var found bool - for _, e := range errs { - if e.Path == "ssh/ssh_deny_password_auth_test.rego" && strings.Contains(e.Message, "violation") && - strings.Contains(e.Message, "vendor test references rules removed by the override of `ssh/ssh_deny_password_auth.rego`; keep the rule or add `delete: [ssh/ssh_deny_password_auth_test.rego]`") { - found = true - } - } - if !found { - t.Fatalf("expected the compile error in the vendor test with the override hint, got %+v", errs) - } - - // Deleting the vendor test makes the bundle compile; the skeleton still has no title, so - // its package would record no evidence: that is an error for an authored package. - m = materialize(t, vendor, &agentconfig.PolicyBundle{ - Extends: strptr("ghcr.io/vendor/policies:v1"), - Delete: []string{"ssh/ssh_deny_password_auth_test.rego"}, - Modules: map[string]string{"ssh/ssh_deny_password_auth.rego": sshOverrideSkeleton}, - }) - errs = errorsOf(check(m, nil), agentconfig.SeverityError) - if len(errs) != 1 || errs[0].Code != agentconfig.PolicyCodeMissingTitle || errs[0].Path != "ssh/ssh_deny_password_auth.rego" { - t.Fatalf("expected one missing-title error on the override, got %+v", errs) - } - - // No hint on a compile error in an authored file. - m = materialize(t, vendor, &agentconfig.PolicyBundle{ - Extends: strptr("ghcr.io/vendor/policies:v1"), - Modules: map[string]string{"ssh/ssh_deny_password_auth.rego": sshVendorModule + "\nbroken if { undefined_var }\n"}, - }) - for _, e := range errorsOf(check(m, nil), agentconfig.SeverityError) { - if strings.Contains(e.Message, "hint:") { - t.Fatalf("an authored compile error must not carry the vendor hint: %+v", e) - } - } -} - -func codes(errs []agentconfig.PolicyError, path string) map[string]string { - out := map[string]string{} - for _, e := range errs { - if e.Path == path { - out[e.Code] = e.Severity - } - } - return out -} - -func TestCheck_Contract_R63(t *testing.T) { - vendor := map[string]string{ - "banner.rego": vendorBanner, - "untitled.rego": "package compliance_framework.untitled\n\nviolation contains {\"id\": \"u\"} if input.x\n", - "badvendor.rego": "package compliance_framework.badvendor\n\ntitle := \"bad vendor\"\n\nviolation contains v if { v := \"not-an-object\" }\n", - } - ext := strptr("ghcr.io/vendor/policies:v1") - - t.Run("authored decode error is an error, vendor one a warning, both reported", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nviolation contains v if { v := 42 }\n", - }}) - res := check(m, nil) - if got := codes(res, "x.rego")[agentconfig.PolicyCodeInvalidViolation]; got != agentconfig.SeverityError { - t.Fatalf("expected an eval error on the authored package, got %+v", res) - } - if got := codes(res, "badvendor.rego")[agentconfig.PolicyCodeInvalidViolation]; got != agentconfig.SeverityWarning { - t.Fatalf("expected a warning on the vendor package, got %+v", res) - } - n := 0 - for _, e := range res { - if e.Path == "badvendor.rego" { - n++ - } - } - if n != 1 { - t.Fatalf("the static and dynamic checks must not both report the vendor package, got %+v", res) - } - }) - - t.Run("vendor-only problems warn once", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n", - }}) - res := check(m, nil) - if errs := errorsOf(res, agentconfig.SeverityError); len(errs) != 0 { - t.Fatalf("vendor debt must not reject, got %+v", errs) - } - n := 0 - for _, e := range res { - if e.Path == "untitled.rego" && e.Code == agentconfig.PolicyCodeMissingTitle { - n++ - } - } - if n != 1 { - t.Fatalf("expected exactly one missing-title warning for the vendor package, got %d in %+v", n, res) - } - }) - - t.Run("an authored test does not make vendor debt an error", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "untitled_test.rego": "package compliance_framework.untitled\n\ntest_ok if { count(violation) == 1 with input as {\"x\": true} }\n", - }}) - if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { - t.Fatalf("vendor debt must stay a warning, got %+v", errs) - } - }) - - t.Run("authored package without a title is rejected", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\nviolation contains {\"id\": \"x\"} if input.x\n", - }}) - if got := codes(check(m, nil), "x.rego")[agentconfig.PolicyCodeMissingTitle]; got != agentconfig.SeverityError { - t.Fatalf("expected a missing-title error, got %q", got) - } - }) - - t.Run("a title that depends on the input only warns", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\" if input.enabled\n", - }}) - if got := codes(check(m, nil), "x.rego")[agentconfig.PolicyCodeMissingTitle]; got != agentconfig.SeverityWarning { - t.Fatalf("expected a missing-title warning, got %q", got) - } - }) - - t.Run("an evaluation conflict on an empty input only warns", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nv := 1\n\nv := 2 if not input.y\n", - }}) - if got := codes(check(m, nil), "x.rego")[codeEvalConflict]; got != agentconfig.SeverityWarning { - t.Fatalf("expected an eval-conflict warning, got %q", got) - } - }) - - t.Run("invalid risk templates of an authored package are an error", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nrisk_templates := [{\"name\": \"r\"}] if true\n", - }}) - got := codes(check(m, nil), "x.rego") - if got[agentconfig.PolicyCodeInvalidRiskTemplate] != agentconfig.SeverityError { - t.Fatalf("expected an invalid-risk-template error, got %v", got) - } - }) - - t.Run("an authored module joining a vendor package duplicates its evidence", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: ext, Modules: map[string]string{ - "banner_extra.rego": "package compliance_framework.banner\n\nremarks := \"more\"\n", - }}) - res := check(m, nil) - if got := codes(res, "banner_extra.rego")[agentconfig.PolicyCodeDuplicatePackageModule]; got != agentconfig.SeverityWarning { - t.Fatalf("expected a duplicate-package-module warning, got %+v", res) - } - }) -} - -// TestCheck_DryRunIsSandboxed: the dry run evaluates vendor packages too, and a denied -// builtin they call must never execute. -func TestCheck_DryRunIsSandboxed(t *testing.T) { - var hits atomic.Int32 - probe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - w.WriteHeader(http.StatusOK) - })) - defer probe.Close() - - m := materialize(t, map[string]string{ - "leak.rego": "package compliance_framework.leak\n\ntitle := \"leak\"\n\nr := http.send({\"method\": \"GET\", \"url\": \"" + probe.URL + "\"})\n", - }, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n", - }}) - if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { - t.Fatalf("unexpected errors %+v", errs) - } - if n := hits.Load(); n != 0 { - t.Fatalf("the probe server received %d request(s) during the dry run", n) - } -} diff --git a/internal/inlinepolicy/identity.go b/internal/inlinepolicy/identity.go deleted file mode 100644 index b4d4298..0000000 --- a/internal/inlinepolicy/identity.go +++ /dev/null @@ -1,188 +0,0 @@ -package inlinepolicy - -import ( - "fmt" - "maps" - "path/filepath" - "slices" - "strings" - - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/pkg/policyeval" - "github.com/open-policy-agent/opa/v1/ast" -) - -// Evidence identity (R75). A plugin seeds each evidence UUID with the policy's package, its -// file (the path the agent passed the plugin joined with the module's path in the bundle) -// and, through its labels, that path. - -// ModuleIdentity is what decides the evidence stream of one policy module. -type ModuleIdentity struct { - Path string // slash-separated, relative to the policy root - Package string // without "data." -} - -// Site is where an authored construct is. -type Site struct { - Path string - Row, Col int -} - -// Identities returns the identity of every non-test module of a compliance_framework -// package in files, sorted by path. Modules that do not parse are skipped: the checks report -// them. -func Identities(files map[string][]byte) []ModuleIdentity { - modules := parseModules(files) - var out []ModuleIdentity - for _, p := range slices.Sorted(maps.Keys(modules)) { - pkg := packageOf(modules[p]) - if policyeval.IsTestFile(p) || !policyeval.IsPolicyPackage(pkg) { - continue - } - out = append(out, ModuleIdentity{Path: p, Package: pkg}) - } - return out -} - -// TreeIdentities reads the policy tree at dir and returns Identities of it. -func TreeIdentities(dir string) ([]ModuleIdentity, error) { - files, _, err := readTree(dir) - if err != nil { - return nil, err - } - return Identities(files), nil -} - -// setViolationSites returns the authored non-test modules of files that define violation as -// a set (`violation contains ...`, R76). -func setViolationSites(files map[string][]byte, authored map[string]bool) []Site { - var out []Site - for _, p := range slices.Sorted(maps.Keys(files)) { - if !authored[p] || !strings.HasSuffix(p, ".rego") || policyeval.IsTestFile(p) { - continue - } - mod := parseRego(p, files[p]) - if mod == nil || !policyeval.IsPolicyPackage(packageOf(mod)) { - continue - } - for _, rule := range mod.Rules { - if policyeval.RuleName(rule) != "violation" || rule.Head.Key == nil || rule.Head.Value != nil { - continue - } - loc := rule.Head.Location - if loc == nil { - loc = rule.Location - } - site := Site{Path: p} - if loc != nil { - site.Row, site.Col = loc.Row, loc.Col - } - out = append(out, site) - break - } - } - return out -} - -// SeedOf returns the evidence seed values (policy_file, _policy_path) of module id when a -// plugin loads it from pluginPath, as policy-manager computes them: OPA gives plugins the -// policy file as the path joined with the module's path (cleaned), and plugins label -// _policy_path with pluginPath as passed. -func SeedOf(id ModuleIdentity, pluginPath string) (string, string) { - return filepath.Join(pluginPath, filepath.FromSlash(id.Path)), pluginPath -} - -// OverrideStreams warns about the authored overrides of an extends bundle that do not keep -// the evidence stream of the vendor module they replace (R75) even when plugins receive the -// bundle at the extends source's path (shadowed): an override at the vendor module's path -// seeds what the vendor module seeds there unless it changes the package -// (policy-package-changed). What not being shadowed costs on top of that is UnshadowedForks. -func OverrideStreams(m *Materialized) []agentconfig.PolicyError { - if m == nil || m.Extends == nil { - return nil - } - vendor := m.vendorModules() - var out []agentconfig.PolicyError - for _, id := range m.Identities { - v, replaced := vendor[id.Path] - if !replaced || !m.Authored[id.Path] || id.Package == v.Package { - continue - } - out = append(out, agentconfig.PolicyError{Bundle: m.Name, Path: id.Path, Severity: agentconfig.SeverityWarning, Code: agentconfig.PolicyCodePolicyPackageChanged, - Message: fmt.Sprintf("the override of %s changes its package from %s to %s, which starts a new evidence stream for it; keep `package %s` to continue the vendor policy's stream", - id.Path, v.Package, id.Package, v.Package)}) - } - return out -} - -// UnshadowedForks returns the paths of the modules of m that keep the evidence stream of the -// vendor module at their path when plugins receive m at the extends source's path, but not -// at m.Path, where they do when m is not shadowed: inherited modules and overrides that keep -// the vendor's package. Nil when m is shadowed or extends nothing. -func UnshadowedForks(m *Materialized) []string { - if m == nil || m.Extends == nil || m.Shadowed { - return nil - } - vendor := m.vendorModules() - var out []string - for _, id := range m.Identities { - v, ok := vendor[id.Path] - if ok && id.Package == v.Package && !sameSeed(id, v, m.Path, m.ExtendsDir) { - out = append(out, id.Path) - } - } - return out -} - -func (m *Materialized) vendorModules() map[string]ModuleIdentity { - vendor := make(map[string]ModuleIdentity, len(m.ExtendsIdentities)) - for _, id := range m.ExtendsIdentities { - vendor[id.Path] = id - } - return vendor -} - -// sameSeed reports whether module id loaded from path seeds evidence like vendor module v -// loaded from vendorPath. -func sameSeed(id, v ModuleIdentity, path, vendorPath string) bool { - file, policyPath := SeedOf(id, path) - vendorFile, vendorPolicyPath := SeedOf(v, vendorPath) - return file == vendorFile && policyPath == vendorPolicyPath -} - -// CodePolicyStreamForked is the PolicyError code of the modules of an extends bundle that is -// not shadowed, which do not continue the evidence stream of the vendor module at their path -// (R88). Warning. -const CodePolicyStreamForked = "policy-stream-forked" - -// parseRego parses a module as Rego v1, then as Rego v0, as plugins on either OPA major -// would load it; nil when it does not parse or has no package. -func parseRego(p string, src []byte) *ast.Module { - for _, v := range []ast.RegoVersion{ast.RegoV1, ast.RegoV0} { - mod, err := ast.ParseModuleWithOpts(p, string(src), ast.ParserOptions{RegoVersion: v}) - if err == nil && mod != nil && mod.Package != nil { - return mod - } - } - return nil -} - -func parseModules(files map[string][]byte) map[string]*ast.Module { - out := map[string]*ast.Module{} - for p, src := range files { - if !strings.HasSuffix(p, ".rego") { - continue - } - if mod := parseRego(p, src); mod != nil { - out[p] = mod - } - } - return out -} - -func packageOf(mod *ast.Module) string { - if mod == nil || mod.Package == nil { - return "" - } - return strings.TrimPrefix(mod.Package.Path.String(), "data.") -} diff --git a/internal/inlinepolicy/identity_test.go b/internal/inlinepolicy/identity_test.go deleted file mode 100644 index c178616..0000000 --- a/internal/inlinepolicy/identity_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package inlinepolicy - -import ( - "context" - "testing" - - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestIdentities(t *testing.T) { - ids := Identities(map[string][]byte{ - "a.rego": []byte("package compliance_framework.a\n\ntitle := \"a\"\n"), - "a_extra.rego": []byte("package compliance_framework.a\n\ntitle2 := \"x\"\n"), - "a_test.rego": []byte("package compliance_framework.a\n\ntest_ok := true\n"), - "b/b.rego": []byte("package compliance_framework.b\n\ntitle := \"b\"\n"), - "lib.rego": []byte("package ccf_libs.helpers\n\nyes := true\n"), - "broken.rego": []byte("package compliance_framework.broken\n\ntitle := \n"), - "data.json": []byte("{}"), - }) - assert.Equal(t, []ModuleIdentity{ - {Path: "a.rego", Package: "compliance_framework.a"}, - {Path: "a_extra.rego", Package: "compliance_framework.a"}, - {Path: "b/b.rego", Package: "compliance_framework.b"}, - }, ids) -} - -func TestSetViolationSites(t *testing.T) { - files := map[string][]byte{ - "set.rego": []byte("package compliance_framework.set\n\nimport rego.v1\n\nviolation contains {\"id\": \"x\"} if input.bad\n"), - "object.rego": []byte("package compliance_framework.object\n\nimport rego.v1\n\nviolation[{\"id\": \"x\"}] if input.bad\n"), - "vendor.rego": []byte("package compliance_framework.vendor\n\nimport rego.v1\n\nviolation contains {\"id\": \"x\"} if input.bad\n"), - "set_test.rego": []byte("package compliance_framework.set\n\nimport rego.v1\n\nviolation contains 1 if true\n"), - } - set := setViolationSites(files, map[string]bool{"set.rego": true, "object.rego": true, "set_test.rego": true}) - assert.Equal(t, []Site{{Path: "set.rego", Row: 5, Col: 1}}, set, "only authored set-form violations; the object form works with every plugin") -} - -// TestOverrideStreams_R75: an override continues the vendor module's stream at the vendor's -// path unless it changes the package. Served at the bundle's own path (not shadowed), the -// modules that keep the vendor's package are UnshadowedForks. -func TestOverrideStreams_R75(t *testing.T) { - _, resolve := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "max_auth.rego": vendorMaxAuth}) - cases := []struct { - name string - modules map[string]string - want map[string]string // path -> code - forks []string - }{ - {"changed override", map[string]string{"banner.rego": vendorBanner + "\n# changed\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, - {"changed package", map[string]string{"max_auth.rego": "package compliance_framework.max_auth_v2\n\ntitle := \"x\"\n"}, map[string]string{"max_auth.rego": agentconfig.PolicyCodePolicyPackageChanged}, []string{"banner.rego"}}, - {"new module", map[string]string{"new.rego": "package compliance_framework.new\n\ntitle := \"x\"\n"}, map[string]string{}, []string{"banner.rego", "max_auth.rego"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: tc.modules}, resolve) - require.NoError(t, err) - got := map[string]string{} - for _, e := range OverrideStreams(m) { - require.Equal(t, agentconfig.SeverityWarning, e.Severity) - require.Equal(t, "b", e.Bundle) - got[e.Path] = e.Code - } - assert.Equal(t, tc.want, got) - assert.Equal(t, tc.forks, UnshadowedForks(m)) - }) - } - m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Modules: map[string]string{"banner.rego": vendorBanner}}, resolve) - require.NoError(t, err) - assert.Empty(t, OverrideStreams(m), "a bundle without extends overrides nothing") - assert.Empty(t, UnshadowedForks(m)) -} diff --git a/internal/inlinepolicy/inlinepolicy_test.go b/internal/inlinepolicy/inlinepolicy_test.go deleted file mode 100644 index cb76ab2..0000000 --- a/internal/inlinepolicy/inlinepolicy_test.go +++ /dev/null @@ -1,459 +0,0 @@ -package inlinepolicy - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "reflect" - "runtime" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/compliance-framework/api/pkg/agentconfig" -) - -// vendorTree writes files under a new directory and returns a resolver serving it. -func vendorTree(t *testing.T, files map[string]string) (string, Resolver) { - t.Helper() - dir := t.TempDir() - for p, content := range files { - dst := filepath.Join(dir, filepath.FromSlash(p)) - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dst, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - return dir, func(_ context.Context, source string) (string, error) { - if source != "ghcr.io/vendor/policies:v1" { - return "", errors.New("unknown source " + source) - } - return dir, nil - } -} - -func strptr(s string) *string { return &s } - -func readFile(t *testing.T, dir, p string) string { - t.Helper() - raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p))) - if err != nil { - t.Fatalf("read %s: %v", p, err) - } - return string(raw) -} - -const vendorBanner = "package compliance_framework.banner\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"no-banner\", \"remarks\": \"no banner\"} if not input.banner\n" -const vendorMaxAuth = "package compliance_framework.max_auth\n\nviolation contains {\"remarks\": \"too many\"} if input.max_auth > 3\n" - -func TestMaterialize_R17Order(t *testing.T) { - _, resolve := vendorTree(t, map[string]string{ - "banner.rego": vendorBanner, - "max_auth.rego": vendorMaxAuth, - "legacy.rego": "package compliance_framework.legacy\n", - "data.yaml": "limits:\n max_auth: 3\n keep: true\n", - "lib/helpers.rego": "package ccf_libs.helpers\n", - "lib/ignored.json": "{}", - "banner_test.rego": "package compliance_framework.banner_test\n", - "sub/data.json": `{"x": 1}`, - "docs/README.md": "# vendor", - "max_auth_test.rego": "package compliance_framework.max_auth_test\n", - "nested/deep/a.rego": "package compliance_framework.a\n", - "nested/deep/data.yml": "k: v\n", - }) - b := &agentconfig.PolicyBundle{ - Extends: strptr("ghcr.io/vendor/policies:v1"), - Delete: []string{"legacy.rego", "missing.rego"}, - Modules: map[string]string{ - "max_auth.rego": "package compliance_framework.max_auth\n# override\n", - "extra/new.rego": "package compliance_framework.extra\n", - "Policies/Max.Auth.rego": "package compliance_framework.mixed\n", - }, - Data: map[string]any{"limits": map[string]any{"max_auth": 5, "keep": nil}}, - } - root := t.TempDir() - m, err := Materialize(context.Background(), testLayout(root), "ssh", b, resolve) - if err != nil { - t.Fatalf("materialize: %v", err) - } - if _, err := os.Stat(filepath.Join(m.Dir, "legacy.rego")); !os.IsNotExist(err) { - t.Fatal("deleted vendor module must be gone") - } - if got := readFile(t, m.Dir, "max_auth.rego"); got != b.Modules["max_auth.rego"] { - t.Fatalf("override not applied: %q", got) - } - if got := readFile(t, m.Dir, "banner.rego"); got != vendorBanner { - t.Fatalf("an inherited module must keep the vendor bytes: %q", got) - } - readFile(t, m.Dir, "extra/new.rego") - readFile(t, m.Dir, "Policies/Max.Auth.rego") - var data map[string]any - if err := json.Unmarshal([]byte(readFile(t, m.Dir, "data.json")), &data); err != nil { - t.Fatal(err) - } - if want := map[string]any{"limits": map[string]any{"max_auth": float64(5)}}; !reflect.DeepEqual(data, want) { - t.Fatalf("data.yaml must be merge-patched into data.json: %#v", data) - } - if _, err := os.Stat(filepath.Join(m.Dir, "data.yaml")); !os.IsNotExist(err) { - t.Fatal("the root data.yaml must be replaced by data.json") - } - var warned []string - for _, w := range m.Warnings { - warned = append(warned, w.Path) - } - if !contains(warned, "missing.rego") || !contains(warned, "lib/ignored.json") { - t.Fatalf("expected warnings for the missing delete and the stray vendor data file, got %v", warned) - } - if !m.Authored["max_auth.rego"] || m.Authored["banner.rego"] || !m.Authored["data.json"] { - t.Fatalf("authored set wrong: %v", m.Authored) - } - if m.Extends == nil || m.Extends.Source != "ghcr.io/vendor/policies:v1" || len(m.Extends.Files) != 12 { - t.Fatalf("extends report wrong: %+v", m.Extends) - } - if !strings.HasPrefix(m.Digest, agentconfig.TreeDigestPrefix) || filepath.Base(filepath.Dir(m.Dir)) != strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix) { - t.Fatalf("digest/dir mismatch: %s %s", m.Digest, m.Dir) - } - for _, f := range m.Files { - if f.Path == "banner.rego" && f.Package != "compliance_framework.banner" { - t.Fatalf("package not inventoried: %+v", f) - } - } - - again, err := Materialize(context.Background(), testLayout(root), "ssh", b, resolve) - if err != nil || again.Dir != m.Dir { - t.Fatalf("the same content must reuse the same directory: %v %s %s", err, again.Dir, m.Dir) - } -} - -func contains(list []string, s string) bool { - for _, v := range list { - if v == s { - return true - } - } - return false -} - -func TestMaterialize_OverlayNullRestoresVendorModule(t *testing.T) { - _, resolve := vendorTree(t, map[string]string{"max_auth.rego": vendorMaxAuth}) - base := agentconfig.Config{PolicyBundles: map[string]*agentconfig.PolicyBundle{"ssh": { - Extends: strptr("ghcr.io/vendor/policies:v1"), - Modules: map[string]string{"max_auth.rego": "package compliance_framework.max_auth\n# file override\n"}, - }}} - merged, err := agentconfig.Merge(base, json.RawMessage(`{"policy_bundles":{"ssh":{"modules":{"max_auth.rego":null}}}}`)) - if err != nil { - t.Fatal(err) - } - m, err := Materialize(context.Background(), testLayout(t.TempDir()), "ssh", merged.PolicyBundles["ssh"], resolve) - if err != nil { - t.Fatal(err) - } - if got := readFile(t, m.Dir, "max_auth.rego"); got != vendorMaxAuth { - t.Fatalf("null must restore the vendor module, got %q", got) - } -} - -func TestMaterialize_Errors(t *testing.T) { - _, resolve := vendorTree(t, map[string]string{"a.rego": "package compliance_framework.a\n"}) - tests := []struct { - name string - b *agentconfig.PolicyBundle - }{ - {"data and data.json", &agentconfig.PolicyBundle{Modules: map[string]string{"data.json": "{}"}, Data: map[string]any{"a": 1}}}, - {"stray json module", &agentconfig.PolicyBundle{Modules: map[string]string{"foo.json": "{}"}}}, - {"parent path", &agentconfig.PolicyBundle{Modules: map[string]string{"../x.rego": "package x"}}}, - {"absolute path", &agentconfig.PolicyBundle{Modules: map[string]string{"/abs.rego": "package x"}}}, - {"escaping path", &agentconfig.PolicyBundle{Modules: map[string]string{"a/../../x.rego": "package x"}}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", tt.b, resolve) - var perrs PolicyErrors - if !errors.As(err, &perrs) || !agentconfig.HasPolicyErrors(perrs) { - t.Fatalf("expected policy errors, got %v", err) - } - }) - } - t.Run("resolver failure", func(t *testing.T) { - _, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/other:v1")}, resolve) - if !errors.Is(err, ErrResolve) { - t.Fatalf("expected ErrResolve, got %v", err) - } - }) -} - -func TestMaterialize_ExtendsRootSymlinkAndEmptyTree(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks") - } - t.Run("symlinked root is followed", func(t *testing.T) { - vendorDir, _ := vendorTree(t, map[string]string{"banner.rego": vendorBanner, "lib/x.rego": "package lib.x\n"}) - link := filepath.Join(t.TempDir(), "policies") - if err := os.Symlink(vendorDir, link); err != nil { - t.Fatal(err) - } - resolve := func(context.Context, string) (string, error) { return link, nil } - m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("/etc/ccf/policies")}, resolve) - if err != nil { - t.Fatal(err) - } - if len(m.Extends.Files) != 2 || len(m.Warnings) != 0 { - t.Fatalf("expected both vendor files through the symlinked root, got %+v (warnings %+v)", m.Extends.Files, m.Warnings) - } - }) - t.Run("a tree without .rego files is an error", func(t *testing.T) { - _, resolve := vendorTree(t, map[string]string{"README.md": "nothing here"}) - _, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) - if !errors.Is(err, ErrResolve) || !strings.Contains(err.Error(), "no .rego files") { - t.Fatalf("expected an ErrResolve for an empty extends tree, got %v", err) - } - }) -} - -func TestMaterialize_SkipsSymlinksInExtends(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks") - } - dir, resolve := vendorTree(t, map[string]string{"a.rego": "package compliance_framework.a\n"}) - secret := filepath.Join(t.TempDir(), "secret.rego") - if err := os.WriteFile(secret, []byte("package secret\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.Symlink(secret, filepath.Join(dir, "link.rego")); err != nil { - t.Fatal(err) - } - m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1")}, resolve) - if err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(filepath.Join(m.Dir, "link.rego")); !os.IsNotExist(err) { - t.Fatal("a symlink must not be materialized") - } - if len(m.Warnings) != 1 || m.Warnings[0].Path != "link.rego" { - t.Fatalf("expected a symlink warning, got %+v", m.Warnings) - } -} - -func materialize(t *testing.T, vendor map[string]string, b *agentconfig.PolicyBundle) *Materialized { - t.Helper() - _, resolve := vendorTree(t, vendor) - m, err := Materialize(context.Background(), testLayout(t.TempDir()), "b", b, resolve) - if err != nil { - t.Fatalf("materialize: %v", err) - } - return m -} - -func check(m *Materialized, policyData map[string]any) []agentconfig.PolicyError { - return Check(context.Background(), CheckInput{Plugin: "ssh", Bundle: m.Name, PolicyDir: m.Dir, Authored: m.Authored, AuthoredTests: m.AuthoredTests, PolicyData: policyData}) -} - -func errorsOf(errs []agentconfig.PolicyError, severity string) []agentconfig.PolicyError { - var out []agentconfig.PolicyError - for _, e := range errs { - if e.Severity == severity { - out = append(out, e) - } - } - return out -} - -func TestCheck_CompileAndBuiltins(t *testing.T) { - vendor := map[string]string{ - "lib/net.rego": "package ccf_libs.net\n\nfetch(u) := http.send({\"method\": \"GET\", \"url\": u})\n\nhelper(x) := x\n", - "banner.rego": vendorBanner, - } - tests := []struct { - name string - modules map[string]string - wantErr string - }{ - {"parse error", map[string]string{"bad.rego": "package compliance_framework.bad\n\nviolation contains x if {"}, "bad.rego"}, - {"direct http.send is located", map[string]string{"x.rego": "package compliance_framework.x\n\nr := http.send({\"method\": \"GET\", \"url\": \"http://x\"})\n"}, "x.rego:3"}, - {"direct http.send", map[string]string{"x.rego": "package compliance_framework.x\n\nr := http.send({\"method\": \"GET\", \"url\": \"http://x\"})\n"}, "http.send"}, - {"vendor helper wrapping http.send", map[string]string{"x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\nr := net.fetch(\"http://x\")\n"}, "http.send"}, - {"with f as http.send", map[string]string{"x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\nr if {\n\tnet.helper(1) with net.helper as http.send\n}\n"}, "reachable from authored rule"}, - {"import from another policy path", map[string]string{"x.rego": "package compliance_framework.x\n\nimport data.other_bundle.lib\n\nr := lib.f(1)\n"}, "x.rego"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: tt.modules}) - errs := errorsOf(check(m, nil), agentconfig.SeverityError) - if len(errs) == 0 { - t.Fatal("expected an error") - } - joined := PolicyErrors(errs).Error() - if !strings.Contains(joined, tt.wantErr) { - t.Fatalf("error %q does not mention %q", joined, tt.wantErr) - } - }) - } - - t.Run("pure builtins are allowed", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\na := net.cidr_contains(\"10.0.0.0/8\", \"10.1.2.3\")\n\nb := rego.parse_module(\"x.rego\", \"package x\")\n\nc if trace(\"hi\")\n", - }}) - if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { - t.Fatalf("unexpected errors %v", errs) - } - }) - - t.Run("vendor-only denied builtins are not attributed to authored rules", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\nimport data.ccf_libs.net\n\ntitle := \"x\"\n\nr := net.helper(1)\n", - }}) - if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 0 { - t.Fatalf("unexpected errors %v", errs) - } - }) - - t.Run("manifest roots excluding the inline package", func(t *testing.T) { - m := materialize(t, map[string]string{ - ".manifest": `{"roots": ["compliance_framework/banner"]}`, - "banner.rego": vendorBanner, - }, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n", - }}) - if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) == 0 { - t.Fatal("a package outside the manifest roots must be rejected") - } - }) -} - -// TestCheck_DeniedBuiltinNeverExecutes is the D17/§8 regression: a denied builtin reachable -// from authored Rego (or called by a vendor test) must never run on the agent host, not even -// while the revision is being rejected. -func TestCheck_DeniedBuiltinNeverExecutes(t *testing.T) { - var hits atomic.Int32 - probe := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits.Add(1) - w.WriteHeader(http.StatusOK) - })) - defer probe.Close() - - vendor := map[string]string{ - "lib/net.rego": "package ccf_libs.net\n\nfetch(u) := http.send({\"method\": \"GET\", \"url\": u})\n", - "banner.rego": vendorBanner, - } - - t.Run("authored test through a vendor helper is rejected before tests run", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x_test.rego": "package compliance_framework.x_test\n\nimport data.ccf_libs.net\n\ntest_x if { net.fetch(\"" + probe.URL + "/exfil?d=secret\") }\n", - }}) - errs := errorsOf(check(m, nil), agentconfig.SeverityError) - if len(errs) == 0 || !strings.Contains(PolicyErrors(errs).Error(), "forbidden builtin http.send") { - t.Fatalf("expected the forbidden builtin error, got %+v", errs) - } - }) - - t.Run("vendor test calling http.send is sandboxed", func(t *testing.T) { - withTest := map[string]string{ - "lib/net_test.rego": "package ccf_libs.net_test\n\nimport data.ccf_libs.net\n\ntest_fetch if { net.fetch(\"" + probe.URL + "/vendor\") }\n\ntest_direct if { http.send({\"method\": \"GET\", \"url\": \"" + probe.URL + "/direct\"}) }\n", - } - for k, v := range vendor { - withTest[k] = v - } - m := materialize(t, withTest, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{ - "x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n", - }}) - res := check(m, nil) - if errs := errorsOf(res, agentconfig.SeverityError); len(errs) != 0 { - t.Fatalf("vendor tests must only warn, got errors %+v", errs) - } - if warns := errorsOf(res, agentconfig.SeverityWarning); len(warns) != 2 { - t.Fatalf("expected both vendor tests to fail as warnings, got %+v", res) - } - }) - - if n := hits.Load(); n != 0 { - t.Fatalf("the probe server received %d request(s); a denied builtin executed during Check", n) - } -} - -func TestCheck_Tests(t *testing.T) { - vendor := map[string]string{ - "banner.rego": vendorBanner, - "banner_test.rego": "package compliance_framework.banner_test\n\nimport data.compliance_framework.banner\n\ntest_fails if { count(banner.violation) == 42 with input as {} }\n", - } - t.Run("failing vendor test warns", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Modules: map[string]string{"x.rego": "package compliance_framework.x\n\ntitle := \"x\"\n\nr := 1\n"}}) - res := check(m, nil) - if len(errorsOf(res, agentconfig.SeverityError)) != 0 || len(errorsOf(res, agentconfig.SeverityWarning)) != 1 { - t.Fatalf("expected exactly one warning, got %+v", res) - } - }) - t.Run("failing authored test rejects", func(t *testing.T) { - m := materialize(t, vendor, &agentconfig.PolicyBundle{Extends: strptr("ghcr.io/vendor/policies:v1"), Delete: []string{"banner_test.rego"}, Modules: map[string]string{ - "x_test.rego": "package compliance_framework.x_test\n\ntest_bad if { 1 == 2 }\n", - }}) - if errs := errorsOf(check(m, nil), agentconfig.SeverityError); len(errs) != 1 || errs[0].Path != "x_test.rego" || errs[0].Row == 0 { - t.Fatalf("expected one authored test error with a location, got %+v", errs) - } - }) - t.Run("policy_data is visible", func(t *testing.T) { - m := materialize(t, map[string]string{}, &agentconfig.PolicyBundle{ - Modules: map[string]string{"x_test.rego": "package compliance_framework.x_test\n\ntest_data if { data.limits.max == 5; data.limits.keep == true }\n"}, - Data: map[string]any{"limits": map[string]any{"keep": true}}, - }) - if res := check(m, map[string]any{"limits": map[string]any{"max": 5}}); len(res) != 0 { - t.Fatalf("expected the test to see bundle data and policy_data, got %+v", res) - } - }) - t.Run("timeout", func(t *testing.T) { - old := TestTimeout - TestTimeout = time.Second - t.Cleanup(func() { TestTimeout = old }) - m := materialize(t, map[string]string{}, &agentconfig.PolicyBundle{Modules: map[string]string{ - "x_test.rego": "package compliance_framework.x_test\n\ntest_slow if { count([x | some x in numbers.range(1, 100000000)]) > 0 }\n", - }}) - start := time.Now() - res := errorsOf(check(m, nil), agentconfig.SeverityError) - if len(res) == 0 || time.Since(start) > 20*time.Second { - t.Fatalf("expected a timeout error within bounds, got %+v after %s", res, time.Since(start)) - } - }) -} - -func TestGC_KeepsActiveAndNewest(t *testing.T) { - root := t.TempDir() - var dirs []string - for i := 0; i < 8; i++ { - d := filepath.Join(root, "store", "ssh", strings.Repeat(string(rune('a'+i)), 4)) - if err := os.MkdirAll(filepath.Join(d, treeDir), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(d, treeMarker), nil, 0o644); err != nil { - t.Fatal(err) - } - mod := time.Now().Add(time.Duration(i) * time.Minute) - if err := os.Chtimes(d, mod, mod); err != nil { - t.Fatal(err) - } - dirs = append(dirs, d) - } - if err := os.MkdirAll(filepath.Join(root, "store", "ssh", ".tmp-abc"), 0o755); err != nil { - t.Fatal(err) - } - keep := map[string]struct{}{dirs[0]: {}} // the oldest is active - if err := GC(testLayout(root), keep, 5); err != nil { - t.Fatal(err) - } - for i, d := range dirs { - _, err := os.Stat(d) - exists := err == nil - want := i == 0 || i >= 3 // active + the 5 newest (3..7) - if exists != want { - t.Fatalf("dir %d exists=%v want %v", i, exists, want) - } - } - if _, err := os.Stat(filepath.Join(root, "store", "ssh", ".tmp-abc")); !os.IsNotExist(err) { - t.Fatal("abandoned temp dirs must be removed") - } -} diff --git a/internal/inlinepolicy/materialize.go b/internal/inlinepolicy/materialize.go deleted file mode 100644 index 3e41f7d..0000000 --- a/internal/inlinepolicy/materialize.go +++ /dev/null @@ -1,544 +0,0 @@ -// Package inlinepolicy materializes inline policy bundles (policy_bundles in the file or the -// remote overlay) into write-once directories that plugins load like any other policy path, -// and checks them the way plugins will evaluate them (HLD §3.5, R17–R21). -// -// It imports api/pkg/agentconfig (and its regocheck), api/pkg/policyeval, -// internal/policytree, internal/policyview, policy-manager (to dry-run bundles through the -// exact calls plugins make), OPA v1 and the standard library, never cmd. -package inlinepolicy - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "maps" - "os" - "path" - "path/filepath" - "slices" - "sort" - "strings" - "sync" - - "github.com/compliance-framework/agent/internal/policytree" - "github.com/compliance-framework/agent/internal/policyview" - "github.com/compliance-framework/api/pkg/agentconfig" - "sigs.k8s.io/yaml" -) - -// Resolver returns the policy root directory of an OCI tag or a local path (the directory -// plugins would receive for it). -type Resolver func(ctx context.Context, source string) (dir string, err error) - -// ErrResolve wraps a failure to fetch a bundle's extends tree (reported as download-failed). -var ErrResolve = errors.New("resolve extends") - -// PolicyErrors is a list of policy problems with at least one error; Materialize returns it -// for bundle content problems (reported as rejected/policy-errors). -type PolicyErrors []agentconfig.PolicyError - -func (p PolicyErrors) Error() string { - parts := make([]string, 0, len(p)) - for _, e := range p { - loc := e.Path - if e.Row > 0 { - loc = fmt.Sprintf("%s:%d:%d", e.Path, e.Row, e.Col) - } - parts = append(parts, fmt.Sprintf("%s %s: %s", e.Bundle, loc, e.Message)) - } - return strings.Join(parts, "; ") -} - -// Layout is where inline bundles live on disk (see docs/configuration.md): -// -// ///policies/... the tree, write-once and content-addressed (Dir) -// / -> // swapped atomically by Activate -// -// A bundle that is not shadowed reaches plugins as //policies, the same path for -// every revision, so unchanged modules keep their evidence streams across edits. The link is -// an intermediate path component because OPA loads nothing from a symlinked root directory. -// Two agents sharing a working directory and a bundle name would swap / under -// each other; run one agent per working directory. -type Layout struct { - // Store holds the content-addressed trees (under the agent's state directory). - Store string - // Links holds each bundle's stable symlink. Plugins receive paths under it. - Links string -} - -// Materialized is one bundle written to disk (see Layout). -type Materialized struct { - Name string - // Dir is the tree itself (///policies). The checks run on it and its - // contents never change. - Dir string - // Path is what plugins receive: the extends source's path when Shadowed, else - // //policies, or Dir when the file system has no symlinks. - Path string - Digest string // agentconfig.BundleTreeDigest(files) - // Extends describes the vendor tree the bundle extends (nil when standalone). - Extends *agentconfig.PolicyBundleExtendsReport - // ExtendsDir is the directory the extends tree was read from. - ExtendsDir string - Files []agentconfig.PolicyFileReport - // Authored are the paths written from Modules (and data.json when Data is set). - Authored map[string]bool - AuthoredTests []string - Warnings []agentconfig.PolicyError // delete of a missing path, skipped symlink, stray data file - - // Identities are the evidence identities of the tree's policy modules, and - // ExtendsIdentities those of the extends tree (R75). - Identities, ExtendsIdentities []ModuleIdentity - // SetViolations are the authored modules that define violation as a set, which plugins - // built on an agent library older than v0.7.1 cannot evaluate (R76). - SetViolations []Site - // Shadowed is set when plugins receive the bundle at the extends source's own path - // (ExtendsDir), resolved to Dir inside each plugin's view (internal/policyview). - Shadowed bool -} - -// Options change how Materialize writes a bundle. -type Options struct { - // Shadow asks for path shadowing: when the bundle extends a source whose plugin path - // policyview.Shadowable accepts, plugins receive that path (Materialized.Shadowed). - // Otherwise it is ignored. - Shadow bool -} - -// Materialize builds bundle name in the R17 order (extends tree, delete, modules, data), -// checks the data-file rule (R18) and writes the result write-once under l.Store. -func Materialize(ctx context.Context, l Layout, name string, b *agentconfig.PolicyBundle, resolve Resolver, opts ...Options) (*Materialized, error) { - var opt Options - for _, o := range opts { - opt.Shadow = opt.Shadow || o.Shadow - } - if b == nil { - return nil, PolicyErrors{{Bundle: name, Message: "bundle has no definition", Severity: agentconfig.SeverityError}} - } - if !agentconfig.BundleNamePattern.MatchString(name) { - return nil, PolicyErrors{{Bundle: name, Message: "invalid bundle name", Severity: agentconfig.SeverityError}} - } - m := &Materialized{Name: name, Authored: map[string]bool{}} - var errs PolicyErrors - warn := func(p, format string, args ...any) { - m.Warnings = append(m.Warnings, agentconfig.PolicyError{Bundle: name, Path: p, Message: fmt.Sprintf(format, args...), Severity: agentconfig.SeverityWarning}) - } - fail := func(p, format string, args ...any) { - errs = append(errs, agentconfig.PolicyError{Bundle: name, Path: p, Message: fmt.Sprintf(format, args...), Severity: agentconfig.SeverityError}) - } - - // 1. Base tree. - files := map[string][]byte{} - if b.Extends != nil { - if resolve == nil { - return nil, fmt.Errorf("%w: no resolver", ErrResolve) - } - dir, err := resolve(ctx, *b.Extends) - if err != nil { - return nil, fmt.Errorf("%w %s: %v", ErrResolve, *b.Extends, err) - } - baseFiles, skipped, err := readTree(dir) - if err != nil { - return nil, fmt.Errorf("%w %s: %v", ErrResolve, *b.Extends, err) - } - if !slices.ContainsFunc(slices.Collect(maps.Keys(baseFiles)), func(p string) bool { return strings.HasSuffix(p, ".rego") }) { - // An empty or unreadable vendor tree would silently drop every vendor policy. - return nil, fmt.Errorf("%w %s: the policy tree at %s has no .rego files", ErrResolve, *b.Extends, dir) - } - for _, s := range skipped { - warn(s, "symlink in the extends tree skipped") - } - m.Extends = &agentconfig.PolicyBundleExtendsReport{ - Source: *b.Extends, - Digest: agentconfig.BundleTreeDigest(baseFiles), - Files: inventory(baseFiles), - } - m.ExtendsDir = dir - m.ExtendsIdentities = Identities(baseFiles) - files = maps.Clone(baseFiles) - } - - // 2. Delete. - for _, p := range b.Delete { - if err := agentconfig.ValidateModulePath(p); err != nil { - fail(p, "%s", err.Error()) - continue - } - if _, ok := files[p]; !ok { - warn(p, "delete: %s is not in the extends tree", p) - continue - } - delete(files, p) - } - - // 3. Modules. - for _, p := range slices.Sorted(maps.Keys(b.Modules)) { - if err := checkRelPath(p); err != nil { - fail(p, "%s", err.Error()) - continue - } - files[p] = []byte(b.Modules[p]) - m.Authored[p] = true - if strings.HasSuffix(p, "_test.rego") { - m.AuthoredTests = append(m.AuthoredTests, p) - } - } - - // 4. Data: RFC 7396 merge patch onto the root data file, emitted as data.json. - if b.Data != nil { - for _, f := range agentconfig.DataFileNames { - if _, ok := b.Modules[f]; ok { - fail(f, "set either data or a root-level %s module, not both", f) - } - } - } - if b.Data != nil && len(errs) == 0 { - if err := mergeRootData(files, b.Data); err != nil { - fail("data.json", "%s", err.Error()) - } else { - m.Authored["data.json"] = true - } - } - - // 5. Data-name rule (R18): OPA only loads data.json/.yaml/.yml; any other data-like file is - // an error when authored and a warning when the vendor shipped it. - for _, p := range slices.Sorted(maps.Keys(files)) { - ext := strings.ToLower(path.Ext(p)) - if ext != ".json" && ext != ".yaml" && ext != ".yml" { - continue - } - if slices.Contains(agentconfig.DataFileNames, path.Base(p)) { - continue - } - if m.Authored[p] { - fail(p, "only data.json, data.yaml or data.yml data files are loaded by OPA") - } else { - warn(p, "OPA ignores %s: only data.json, data.yaml or data.yml data files are loaded", p) - } - } - if len(errs) > 0 { - agentconfig.SortPolicyErrors(errs) - return nil, errs - } - - m.SetViolations = setViolationSites(files, m.Authored) - m.Shadowed = opt.Shadow && m.Extends != nil && policyview.Shadowable(m.ExtendsDir) == nil - - // 6. Write once. - m.Digest = agentconfig.BundleTreeDigest(files) - final := filepath.Join(l.Store, name, strings.TrimPrefix(m.Digest, agentconfig.TreeDigestPrefix)) - if err := writeOnce(final, files); err != nil { - return nil, err - } - m.Dir = filepath.Join(final, treeDir) - m.Path = m.Dir - switch { - case m.Shadowed: - m.Path = m.ExtendsDir - case symlinksSupported(l.Links): - m.Path = filepath.Join(l.Links, name, treeDir) - } - - // 7. Inventory: the tree as written, so Files matches Digest and the uploaded artifact. - m.Files = inventory(files) - slices.Sort(m.AuthoredTests) - m.Identities = Identities(files) - return m, nil -} - -// checkRelPath applies ValidateModulePath and re-checks that the cleaned path stays under the -// policy root. -func checkRelPath(p string) error { - if err := agentconfig.ValidateModulePath(p); err != nil { - return err - } - clean := filepath.Clean(filepath.FromSlash(p)) - if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - return fmt.Errorf("module path %q escapes the policy root", p) - } - return nil -} - -// mergeRootData merge-patches data onto the root data file (data.json, else a converted -// data.yaml/data.yml) and writes the result as data.json, removing the YAML files. -func mergeRootData(files map[string][]byte, data map[string]any) error { - target := []byte("{}") - for _, name := range []string{"data.yaml", "data.yml"} { - if raw, ok := files[name]; ok { - converted, err := yaml.YAMLToJSON(raw) - if err != nil { - return fmt.Errorf("%s does not parse: %v", name, err) - } - merged, err := agentconfig.MergePatch(target, converted) - if err != nil { - return err - } - target = merged - delete(files, name) - } - } - if raw, ok := files["data.json"]; ok { - merged, err := agentconfig.MergePatch(target, raw) - if err != nil { - return fmt.Errorf("data.json does not parse: %v", err) - } - target = merged - } - patch, err := json.Marshal(data) - if err != nil { - return err - } - out, err := agentconfig.MergePatch(target, patch) - if err != nil { - return err - } - files["data.json"] = out - return nil -} - -// readTree reads a policy tree the way every consumer does (see policytree.ReadTree). -func readTree(dir string) (map[string][]byte, []string, error) { - return policytree.ReadTree(dir) -} - -// Inventory digests and lists a policy directory (OCI or local sources) for the report. -func Inventory(dir string) (string, []agentconfig.PolicyFileReport, error) { - files, _, err := readTree(dir) - if err != nil { - return "", nil, err - } - return agentconfig.BundleTreeDigest(files), inventory(files), nil -} - -func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { - out := make([]agentconfig.PolicyFileReport, 0, len(files)) - for _, p := range slices.Sorted(maps.Keys(files)) { - sum := sha256.Sum256(files[p]) - r := agentconfig.PolicyFileReport{Path: p, SHA256: hex.EncodeToString(sum[:])} - if strings.HasSuffix(p, ".rego") { - if mod := parseRego(p, files[p]); mod != nil { - r.Package = packageOf(mod) - } - } - out = append(out, r) - } - return out -} - -const ( - // treeDir is the directory holding the policy tree inside a materialized directory, the - // last component of the path plugins receive (like an OCI source's policies/). - treeDir = "policies" - // treeMarker marks a complete materialized directory. It sits next to treeDir, outside - // the tree. - treeMarker = ".ccf-tree" - // Name prefixes of transient entries in a bundle directory, and in Links. - tmpPrefix = ".tmp-" - symlinkProbeEntry = ".symlink-probe-" -) - -// writeOnce writes files under final/policies unless final is already complete: a temp dir -// (dirs 0755, files 0644) with the completion marker, renamed into place. Losing a rename -// race reuses the winner's directory. -func writeOnce(final string, files map[string][]byte) error { - if complete(final) { - return nil - } - parent := filepath.Dir(final) - if err := os.MkdirAll(parent, 0o755); err != nil { - return err - } - tmp := filepath.Join(parent, tmpPrefix+randomSuffix()) - if err := os.MkdirAll(filepath.Join(tmp, treeDir), 0o755); err != nil { - return err - } - cleanup := func() { _ = os.RemoveAll(tmp) } - for p, content := range files { - dst := filepath.Join(tmp, treeDir, filepath.FromSlash(p)) - if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { - cleanup() - return err - } - if err := os.WriteFile(dst, content, 0o644); err != nil { - cleanup() - return err - } - } - if err := os.WriteFile(filepath.Join(tmp, treeMarker), nil, 0o644); err != nil { - cleanup() - return err - } - if err := os.Rename(tmp, final); err != nil { - cleanup() - if complete(final) { - return nil - } - return err - } - return nil -} - -// complete reports whether final is a materialized directory: the marker and a real -// policies/ directory. -func complete(final string) bool { - info, err := os.Stat(filepath.Join(final, treeMarker)) - if err != nil || !info.Mode().IsRegular() { - return false - } - tree, err := os.Lstat(filepath.Join(final, treeDir)) - return err == nil && tree.IsDir() -} - -func randomSuffix() string { - var rnd [6]byte - _, _ = rand.Read(rnd[:]) - return hex.EncodeToString(rnd[:]) -} - -var symlinkSupport sync.Map // root -> bool - -// symlinksSupported reports, once per root, whether symlinks can be created under root -// (not on Windows without the privilege, nor on some network or FAT file systems). -func symlinksSupported(root string) bool { - if v, ok := symlinkSupport.Load(root); ok { - return v.(bool) - } - ok := func() bool { - if err := os.MkdirAll(root, 0o755); err != nil { - return false - } - probe := filepath.Join(root, symlinkProbeEntry+randomSuffix()) - defer func() { _ = os.Remove(probe) }() - return os.Symlink(".", probe) == nil - }() - symlinkSupport.Store(root, ok) - return ok -} - -// Activate points / at dir, a Materialized.Dir of that bundle under l.Store, -// by renaming a temporary symlink over it. That is atomic for lookups on Linux only (APFS may -// fail a racing lookup with EINVAL) and never a snapshot for a reader walking the tree, so -// callers swap only while no plugin of the previous configuration runs, and serialize -// Activate with GC. It is a no-op when the tree is already active or without symlinks. -func Activate(l Layout, name, dir string) error { - versionDir := filepath.Dir(filepath.Clean(dir)) - if filepath.Base(filepath.Clean(dir)) != treeDir || filepath.Dir(versionDir) != filepath.Join(l.Store, name) { - return fmt.Errorf("activate %s: %s is not a materialized tree of the bundle", name, dir) - } - if !symlinksSupported(l.Links) { - return nil - } - if !complete(versionDir) { - return fmt.Errorf("activate %s: the materialized tree %s is missing", name, dir) - } - // An absolute target: the link lives next to the policy cache and the tree under the - // state directory, which may be anywhere. - target, err := filepath.Abs(versionDir) - if err != nil { - return fmt.Errorf("activate %s: %w", name, err) - } - link := filepath.Join(l.Links, name) - if cur, err := os.Readlink(link); err == nil && cur == target { - return nil - } - if err := os.MkdirAll(l.Links, 0o755); err != nil { - return fmt.Errorf("activate %s: %w", name, err) - } - tmp := filepath.Join(l.Links, tmpPrefix+name+"-"+randomSuffix()) - if err := os.Symlink(target, tmp); err != nil { - return fmt.Errorf("activate %s: %w", name, err) - } - if err := os.Rename(tmp, link); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("activate %s: %w", name, err) - } - return nil -} - -// GC removes materialized directories under l.Store except those in keep (Materialized.Dir -// values, or their parent), the one each bundle's stable link points to, and the perBundle -// newest per bundle, plus abandoned temporary entries. Callers serialize GC with Activate. -func GC(l Layout, keep map[string]struct{}, perBundle int) error { - var errs []error - // Abandoned temporary links of Activate. - if entries, err := os.ReadDir(l.Links); err == nil { - for _, e := range entries { - if strings.HasPrefix(e.Name(), tmpPrefix) { - errs = append(errs, os.Remove(filepath.Join(l.Links, e.Name()))) - } - } - } - bundles, err := os.ReadDir(l.Store) - if errors.Is(err, os.ErrNotExist) { - return errors.Join(errs...) - } - if err != nil { - return err - } - for _, b := range bundles { - if !b.IsDir() { - continue - } - bundleDir := filepath.Join(l.Store, b.Name()) - entries, err := os.ReadDir(bundleDir) - if err != nil { - errs = append(errs, err) - continue - } - active := "" - if target, err := os.Readlink(filepath.Join(l.Links, b.Name())); err == nil { - active = absPath(target) - } - type dirInfo struct { - path string - mod int64 - } - var dirs []dirInfo - for _, e := range entries { - p := filepath.Join(bundleDir, e.Name()) - if strings.HasPrefix(e.Name(), tmpPrefix) { - errs = append(errs, os.RemoveAll(p)) - continue - } - if !e.IsDir() { - continue - } - if absPath(p) == active { - continue - } - if _, ok := keep[p]; ok { - continue - } - if _, ok := keep[filepath.Join(p, treeDir)]; ok { - continue - } - info, err := e.Info() - if err != nil { - continue - } - dirs = append(dirs, dirInfo{p, info.ModTime().UnixNano()}) - } - sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod > dirs[j].mod }) - for i, d := range dirs { - if i >= perBundle { - errs = append(errs, os.RemoveAll(d.path)) - } - } - } - return errors.Join(errs...) -} - -// absPath returns p made absolute and cleaned, or p cleaned when that fails. -func absPath(p string) string { - if abs, err := filepath.Abs(p); err == nil { - return abs - } - return filepath.Clean(p) -} - -// SymlinksSupported reports, once per root, whether symlinks can be created under root. -func SymlinksSupported(root string) bool { return symlinksSupported(root) } diff --git a/internal/inlinepolicy/streams_test.go b/internal/inlinepolicy/streams_test.go deleted file mode 100644 index 215d183..0000000 --- a/internal/inlinepolicy/streams_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package inlinepolicy - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "os" - "path/filepath" - "testing" - - policyManager "github.com/compliance-framework/agent/policy-manager" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/hashicorp/go-hclog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Evidence streams of an inline bundle that extends a vendor bundle: shadowed, plugins -// receive the vendor's path and every module that keeps its package keeps its stream; not -// shadowed, plugins receive the bundle's own path and its modules start path-based streams -// (R88), which UnshadowedForks lists. - -const ( - // streamsVendor is where the agent extracts the vendor OCI bundle: relative to the - // working directory, ending in the artifact's policies directory. - streamsVendor = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" - // streamsLinks is where the agent links inline bundles (cmd's inlineLinksDir). - streamsLinks = ".compliance-framework/policies/_inline" - streamsSource = "ghcr.io/compliance-framework/plugin-local-ssh-policies:v0.2.0" -) - -var streamsVendorFiles = map[string]string{ - "ssh/require_key_based_ssh.rego": "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH\"\n\nviolation contains {\"id\": \"k\"} if input.password\n", - "ssh/deny_root_login.rego": "package compliance_framework.deny_root_login\n\nimport rego.v1\n\ntitle := \"No root login\"\n\nviolation contains {\"id\": \"r\"} if input.root\n", - "banner.rego": "package compliance_framework.banner\n\nimport rego.v1\n\ntitle := \"Banner\"\n\nviolation contains {\"id\": \"b\"} if not input.banner\n", - "ssh/require_key_based_ssh_test.rego": "package compliance_framework.require_key_based_ssh_test\n\nimport rego.v1\n\ntest_ok if true\n", - "lib/helpers.rego": "package ccf_libs.helpers\n\nimport rego.v1\n\nyes := true\n", -} - -// streamsSetup writes the vendor tree at streamsVendor in a new working directory and -// returns the layout the agent uses there (the store under the state directory, absolute as -// CCF_STATE_DIR would give it) and a resolver that returns the literal relative vendor path. -func streamsSetup(t *testing.T) (Layout, Resolver) { - t.Helper() - wd := t.TempDir() - t.Chdir(wd) - skipWithoutSymlinks(t, wd) - for p, src := range streamsVendorFiles { - dst := filepath.Join(streamsVendor, filepath.FromSlash(p)) - require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) - require.NoError(t, os.WriteFile(dst, []byte(src), 0o644)) - } - l := Layout{Store: filepath.Join(wd, ".compliance-framework", "state", "local-dev", "inline"), Links: streamsLinks} - return l, func(_ context.Context, source string) (string, error) { - require.Equal(t, streamsSource, source) - return streamsVendor, nil - } -} - -// streamsMaterialize materializes and activates bundle "custom" with modules over the vendor. -func streamsMaterialize(t *testing.T, l Layout, resolve Resolver, modules map[string]string, opts ...Options) *Materialized { - t.Helper() - m, err := Materialize(context.Background(), l, "custom", &agentconfig.PolicyBundle{ - Extends: strptr(streamsSource), - Modules: modules, - }, resolve, opts...) - require.NoError(t, err) - require.NoError(t, Activate(l, "custom", m.Dir)) - return m -} - -type streamsEvidence struct { - uuid string - labels map[string]string -} - -// streamsRun evaluates policyPath the way the ssh plugin does (labels with _policy_path) -// from working directory dir and returns each package's evidence. -func streamsRun(t *testing.T, dir, policyPath string) map[string]streamsEvidence { - t.Helper() - back, err := os.Getwd() - require.NoError(t, err) - require.NoError(t, os.Chdir(dir)) - defer func() { _ = os.Chdir(back) }() - labels := map[string]string{"type": "ssh", "hostname": "kube-prod-worker-4", "_policy_path": policyPath} - evidence, err := policyManager.NewPolicyProcessor(hclog.NewNullLogger(), labels, nil, nil, nil, nil, nil, nil). - GenerateResults(context.Background(), policyPath, map[string]any{"password": true}) - require.NoError(t, err) - out := map[string]streamsEvidence{} - for _, e := range evidence { - out[e.Labels["_policy"]] = streamsEvidence{e.UUID, e.Labels} - } - require.NotEmpty(t, out, "no evidence at %s", policyPath) - return out -} - -// shadowView links the shadowed vendor path to m's tree in a new directory, as the agent's -// per-plugin view does (internal/policyview), and returns that directory. -func shadowView(t *testing.T, m *Materialized) string { - t.Helper() - view := t.TempDir() - link := filepath.Join(view, filepath.Dir(streamsVendor)) - require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o755)) - target, err := filepath.Abs(filepath.Dir(m.Dir)) - require.NoError(t, err) - require.NoError(t, os.Symlink(target, link)) - return view -} - -// TestStreams_UnshadowedBundleStartsPathStreams: a bundle plugins receive at its own path -// keeps the vendor files as they are, so its inherited modules start path-based streams -// under the relative _inline path (UnshadowedForks). Another revision keeps the path and the -// streams (R67). -func TestStreams_UnshadowedBundleStartsPathStreams(t *testing.T) { - l, resolve := streamsSetup(t) - m := streamsMaterialize(t, l, resolve, map[string]string{ - "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", - }) - assert.False(t, m.Shadowed) - assert.Equal(t, streamsLinks+"/custom/policies", filepath.ToSlash(m.Path)) - for p, src := range streamsVendorFiles { - assert.Equal(t, src, readFile(t, m.Dir, p), "%s must keep the vendor bytes", p) - } - - wd, err := os.Getwd() - require.NoError(t, err) - got := streamsRun(t, wd, m.Path) - assert.Len(t, got, 4, "the three vendor policies and the new one") - vendor := streamsRun(t, wd, streamsVendor) - for _, pkg := range []string{"compliance_framework.require_key_based_ssh", "compliance_framework.banner", "compliance_framework.deny_root_login"} { - require.Contains(t, got, pkg) - assert.NotEqual(t, vendor[pkg].uuid, got[pkg].uuid, "%s starts a path-based stream", pkg) - assert.Equal(t, m.Path, got[pkg].labels["_policy_path"]) - } - - assert.Empty(t, OverrideStreams(m), "no override") - assert.Equal(t, []string{"banner.rego", "ssh/deny_root_login.rego", "ssh/require_key_based_ssh.rego"}, UnshadowedForks(m)) - - // The report inventories the tree as written; the vendor files stay in the extends report. - assert.Equal(t, sha(streamsVendorFiles["banner.rego"]), fileSHA(m.Extends.Files, "banner.rego")) - assert.Equal(t, sha(streamsVendorFiles["banner.rego"]), fileSHA(m.Files, "banner.rego")) - - again := streamsMaterialize(t, l, resolve, map[string]string{ - "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New v2\"\n\nviolation contains {\"id\": \"n\"} if input.password\n", - }) - assert.NotEqual(t, m.Dir, again.Dir) - assert.Equal(t, m.Path, again.Path) - after := streamsRun(t, wd, again.Path) - for pkg, e := range got { - assert.Equal(t, e.uuid, after[pkg].uuid, "%s keeps its stream across revisions", pkg) - } -} - -// TestStreams_Shadowed: with Options.Shadow and a shadowable extends path, plugins receive -// the extends path itself, and in a view every inherited module and every override that -// keeps its package keeps the vendor stream, with no warning. An absolute extends path -// ignores the option. -func TestStreams_Shadowed(t *testing.T) { - l, resolve := streamsSetup(t) - const path = "ssh/require_key_based_ssh.rego" - const pkg = "compliance_framework.require_key_based_ssh" - m := streamsMaterialize(t, l, resolve, map[string]string{ - path: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"Key based SSH (tuned)\"\n\nviolation contains {\"id\": \"k2\"} if input.password\n", - "custom/new.rego": "package compliance_framework.custom_new\n\nimport rego.v1\n\ntitle := \"New\"\n\nviolation[{\"id\": \"n\"}] if input.password\n", - }, Options{Shadow: true}) - assert.True(t, m.Shadowed) - assert.Equal(t, streamsVendor, m.Path, "plugins receive the vendor's path string") - assert.Empty(t, OverrideStreams(m)) - assert.Empty(t, UnshadowedForks(m)) - - wd, err := os.Getwd() - require.NoError(t, err) - vendor := streamsRun(t, wd, streamsVendor) - got := streamsRun(t, shadowView(t, m), streamsVendor) - for _, p := range []string{pkg, "compliance_framework.banner", "compliance_framework.deny_root_login"} { - assert.Equal(t, vendor[p].uuid, got[p].uuid, "%s keeps the vendor stream", p) - } - assert.Contains(t, got, "compliance_framework.custom_new") - - abs, err := filepath.Abs(streamsVendor) - require.NoError(t, err) - m, err = Materialize(context.Background(), l, "custom", &agentconfig.PolicyBundle{Extends: strptr(streamsSource)}, - func(context.Context, string) (string, error) { return abs, nil }, Options{Shadow: true}) - require.NoError(t, err) - assert.False(t, m.Shadowed, "an absolute extends path cannot be shadowed") - assert.Equal(t, streamsLinks+"/custom/policies", filepath.ToSlash(m.Path)) -} - -// TestStreams_Overrides: the R75 warnings about overrides, shadowed or not, and what not -// being shadowed costs. -func TestStreams_Overrides(t *testing.T) { - const keyBased = "ssh/require_key_based_ssh.rego" - const rootLogin = "ssh/deny_root_login.rego" - codes := func(warnings []agentconfig.PolicyError) map[string]string { - out := map[string]string{} - for _, w := range warnings { - if w.Path != "" { - out[w.Path] = w.Code - } - } - return out - } - for _, tc := range []struct { - name string - shadow bool - modules map[string]string - want map[string]string // path -> code - forks []string // UnshadowedForks - }{ - { - name: "shadowed, same package", - shadow: true, - modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, - want: map[string]string{}, - }, - { - name: "shadowed, changed package", - shadow: true, - modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh_v2\n\nimport rego.v1\n\ntitle := \"v2\"\n"}, - want: map[string]string{keyBased: agentconfig.PolicyCodePolicyPackageChanged}, - }, - { - name: "not shadowed, same package", - modules: map[string]string{keyBased: "package compliance_framework.require_key_based_ssh\n\nimport rego.v1\n\ntitle := \"tuned\"\n"}, - want: map[string]string{}, - forks: []string{"banner.rego", rootLogin, keyBased}, - }, - { - name: "not shadowed, changed package", - modules: map[string]string{rootLogin: "package compliance_framework.deny_root_login_v2\n\nimport rego.v1\n\ntitle := \"v2\"\n"}, - want: map[string]string{rootLogin: agentconfig.PolicyCodePolicyPackageChanged}, - forks: []string{"banner.rego", keyBased}, - }, - } { - t.Run(tc.name, func(t *testing.T) { - l, resolve := streamsSetup(t) - m := streamsMaterialize(t, l, resolve, tc.modules, Options{Shadow: tc.shadow}) - require.Equal(t, tc.shadow, m.Shadowed) - warnings := OverrideStreams(m) - assert.Equal(t, tc.want, codes(warnings)) - assert.Equal(t, tc.forks, UnshadowedForks(m)) - for _, w := range warnings { - assert.Equal(t, agentconfig.SeverityWarning, w.Severity) - } - }) - } -} - -func sha(s string) string { - sum := sha256.Sum256([]byte(s)) - return hex.EncodeToString(sum[:]) -} - -func fileSHA(files []agentconfig.PolicyFileReport, p string) string { - for _, f := range files { - if f.Path == p { - return f.SHA256 - } - } - return "" -} diff --git a/internal/policytree/policytree.go b/internal/policytree/policytree.go index e57e3c1..9df97a9 100644 --- a/internal/policytree/policytree.go +++ b/internal/policytree/policytree.go @@ -1,25 +1,30 @@ -// Package policytree reads policy trees from disk and archives them. It is the one place -// that decides which files a policy tree has, so every consumer sees the same tree: inline -// bundle materialization, the report inventory, and the policy bundle artifacts uploaded at -// configuration time (the reconciler) and at evaluation time (the plugins' API helper). -// Uploading the same directory from either place therefore produces the same bytes, and the -// API assigns the same artifact digest (R62). -// -// The package is a leaf: it imports only the standard library. +// Package policytree reads policy trees from disk, inventories them and archives them. It is +// the one place that decides which files a policy tree has, so every consumer sees the same +// tree: the report inventory, and the policy bundle artifacts uploaded at configuration time +// (the reconciler) and at evaluation time (the plugins' API helper). Uploading the same +// directory from either place therefore produces the same bytes, and the API assigns the +// same artifact digest (R62). package policytree import ( "archive/tar" "bytes" + "crypto/sha256" + "encoding/hex" "io/fs" + "maps" "os" "path/filepath" "slices" + "strings" + + "github.com/compliance-framework/api/pkg/agentconfig" + "github.com/open-policy-agent/opa/v1/ast" ) // ReadTree reads the regular files under dir, keyed by their slash-separated path relative // to dir. dir itself may be a symlink (for example /etc/ccf/policies -> a versioned -// directory, or an inline bundle's stable path); symlinks inside the tree are skipped and +// directory); symlinks inside the tree are skipped and // returned, as OPA's bundle loader skips them too. Other non-regular files are ignored. func ReadTree(dir string) (files map[string][]byte, skipped []string, err error) { files = map[string][]byte{} @@ -91,3 +96,40 @@ func TarDirectory(dir string) ([]byte, error) { } return TarFiles(files) } + +// Inventory digests and lists the policy tree at dir (an OCI or local policy source) for the +// config report: the tree digest (agentconfig.BundleTreeDigest) and every file with its +// SHA-256 and, for a Rego module, its package. +func Inventory(dir string) (string, []agentconfig.PolicyFileReport, error) { + files, _, err := ReadTree(dir) + if err != nil { + return "", nil, err + } + return agentconfig.BundleTreeDigest(files), inventory(files), nil +} + +func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { + out := make([]agentconfig.PolicyFileReport, 0, len(files)) + for _, p := range slices.Sorted(maps.Keys(files)) { + sum := sha256.Sum256(files[p]) + r := agentconfig.PolicyFileReport{Path: p, SHA256: hex.EncodeToString(sum[:])} + if strings.HasSuffix(p, ".rego") { + r.Package = packageOf(p, files[p]) + } + out = append(out, r) + } + return out +} + +// packageOf returns the package of a Rego module without the leading "data.", parsing it as +// Rego v1, then as Rego v0, as plugins on either OPA major would load it. It is "" when the +// module does not parse. +func packageOf(p string, src []byte) string { + for _, v := range []ast.RegoVersion{ast.RegoV1, ast.RegoV0} { + mod, err := ast.ParseModuleWithOpts(p, string(src), ast.ParserOptions{RegoVersion: v}) + if err == nil && mod != nil && mod.Package != nil { + return strings.TrimPrefix(mod.Package.Path.String(), "data.") + } + } + return "" +} diff --git a/internal/policytree/policytree_test.go b/internal/policytree/policytree_test.go index 7d4cb41..4534abd 100644 --- a/internal/policytree/policytree_test.go +++ b/internal/policytree/policytree_test.go @@ -3,11 +3,15 @@ package policytree import ( "archive/tar" "bytes" + "crypto/sha256" + "encoding/hex" "io" "os" "path/filepath" "runtime" "testing" + + "github.com/compliance-framework/api/pkg/agentconfig" ) func write(t *testing.T, path, content string) { @@ -95,3 +99,49 @@ func TestTarFiles_DeterministicAndSorted(t *testing.T) { t.Fatalf("entries = %v", names) } } + +func TestInventory(t *testing.T) { + dir := t.TempDir() + write(t, filepath.Join(dir, "banner.rego"), "package compliance_framework.banner\n\nviolation contains {\"id\": \"b\"} if not input.banner\n") + write(t, filepath.Join(dir, "legacy", "v0.rego"), "package compliance_framework.legacy\n\nviolation[{\"id\": \"l\"}] { input.bad }\n") + write(t, filepath.Join(dir, "broken.rego"), "package\n") + write(t, filepath.Join(dir, "data.json"), "{}") + + digest, files, err := Inventory(dir) + if err != nil { + t.Fatal(err) + } + tree, _, err := ReadTree(dir) + if err != nil { + t.Fatal(err) + } + if want := agentconfig.BundleTreeDigest(tree); digest != want { + t.Fatalf("digest = %q, want the tree digest %q", digest, want) + } + want := map[string]string{ + "banner.rego": "compliance_framework.banner", + "broken.rego": "", + "data.json": "", + "legacy/v0.rego": "compliance_framework.legacy", + } + if len(files) != len(want) { + t.Fatalf("files = %+v", files) + } + for i, f := range files { + if i > 0 && files[i-1].Path >= f.Path { + t.Fatalf("files must be sorted by path: %+v", files) + } + pkg, ok := want[f.Path] + if !ok || f.Package != pkg { + t.Fatalf("file %s: package %q, want %q (known %v)", f.Path, f.Package, pkg, ok) + } + sum := sha256.Sum256(tree[f.Path]) + if f.SHA256 != hex.EncodeToString(sum[:]) { + t.Fatalf("file %s: sha256 %s", f.Path, f.SHA256) + } + } + + if _, _, err := Inventory(filepath.Join(dir, "missing")); err == nil { + t.Fatal("a missing tree must be an error") + } +} diff --git a/internal/policyview/policyview.go b/internal/policyview/policyview.go deleted file mode 100644 index 545be3c..0000000 --- a/internal/policyview/policyview.go +++ /dev/null @@ -1,380 +0,0 @@ -// Package policyview builds per-plugin working directories ("views") in which a plugin sees -// an inline policy bundle at the exact relative path of the source the bundle extends (path -// shadowing; see docs/configuration.md and ADR 0003). A plugin that keeps receiving the -// vendor's path string keeps the vendor's evidence streams, whatever agent library it was -// built with: -// -// /.compliance-framework/policies// -> // (symlink) -// /.compliance-framework/policies///policies (real dir, inside the tree) -// -// The link sits at the path's parent because OPA loads nothing from a symlinked root. Every -// other entry of the agent's working directory is mirrored into the view's real directories -// as a symlink, so other relative paths resolve as they do for the agent. -// -// Ownership (rule 1): view directories and shadow links are the agent's. A real entry where -// a mirror link would go was created by the plugin: Ensure keeps it, warns once and carries -// on. A real entry at a shadow link's own path is a conflict (*LinkConflictError). -// -// Views are content-addressed by base and links, so a new bundle revision is a new view and -// nothing is swapped under a running plugin; Ensure is idempotent. The package imports only -// the standard library. -package policyview - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "maps" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - "sync" - "sync/atomic" -) - -// TreeDir is the last component a shadowed path must have: the inline store keeps each -// bundle's tree in a real policies/ directory, which the view link's target contains. -const TreeDir = "policies" - -// View is one plugin's working directory. -type View struct { - // Dir is the view directory (absolute). - Dir string - // Base is the agent's working directory (absolute): what every relative path that is not - // shadowed resolves against. - Base string - // Links maps a slash-separated path relative to the view to the absolute directory it - // links to. - Links map[string]string - // Warn, when set, receives Ensure's warnings as a message and key/value pairs (an - // hclog.Logger's Warn fits). Each plugin-owned entry is reported once per process. - Warn func(msg string, args ...interface{}) -} - -// LinkConflictError is Ensure's error when a shadow link's path holds an entry that is not -// a symlink: only the plugin can have put it there, and the plugin would not see the -// bundle at its policy path. -type LinkConflictError struct { - // View is the view directory, Link the slash-separated path relative to it. - View, Link string -} - -func (e *LinkConflictError) Error() string { - return fmt.Sprintf("view %s: the shadowed policy path's link %s holds an entry the plugin created (not a symlink); "+ - "the agent does not remove plugin-owned entries: delete %s (the view is rebuilt) or stop the plugin replacing it", - e.View, e.Link, filepath.Join(e.View, filepath.FromSlash(e.Link))) -} - -// warned holds the "\x00" of the plugin-owned entries already warned about. -var warned sync.Map - -// Shadowable reports whether a plugin path can be shadowed: a relative path, inside the -// working directory, with a parent below it, whose last component is TreeDir (every OCI -// source; a local source only when laid out the same way). -func Shadowable(pluginPath string) error { - if pluginPath == "" { - return errors.New("empty path") - } - if filepath.IsAbs(pluginPath) || filepath.VolumeName(pluginPath) != "" { - return fmt.Errorf("%s is absolute; only relative paths can be shadowed", pluginPath) - } - clean := filepath.Clean(pluginPath) - if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - return fmt.Errorf("%s is outside the working directory", pluginPath) - } - if filepath.Base(clean) != TreeDir { - return fmt.Errorf("%s does not end in %s/", pluginPath, TreeDir) - } - if filepath.Dir(clean) == "." { - return fmt.Errorf("%s has no parent directory to link", pluginPath) - } - return nil -} - -// LinkOf is the view path linked for shadowed plugin path p (its cleaned parent). -func LinkOf(p string) string { - return filepath.ToSlash(filepath.Dir(filepath.Clean(p))) -} - -// Plan checks that a plugin whose policy paths are shadowed (shadowed plugin paths) and -// others (every other policy path it receives) can be given one view, and returns the view -// link of each shadowed path. It fails when: -// -// - two shadowed paths are the same, or one's link is inside another's; -// - another relative path resolves into a shadowed tree, or is itself a view directory or -// sits directly in one (its leaf would be a mirrored symlink, which OPA does not load). -// -// Absolute paths are unaffected by the view. -func Plan(shadowed, others []string) (map[string]string, error) { - links := map[string]string{} - seen := map[string]string{} - for _, p := range shadowed { - if err := Shadowable(p); err != nil { - return nil, err - } - link := LinkOf(p) - if prev, dup := seen[link]; dup { - return nil, fmt.Errorf("%s and %s are the same path", prev, p) - } - seen[link] = p - links[p] = link - } - for a := range seen { - for b := range seen { - if a != b && within(b, a) { - return nil, fmt.Errorf("%s is inside %s", seen[b], seen[a]) - } - } - } - dirs := viewDirs(seen) - for _, p := range others { - if filepath.IsAbs(p) || filepath.VolumeName(p) != "" { - continue - } - clean := filepath.ToSlash(filepath.Clean(p)) - for link, sp := range seen { - if clean == link || within(clean, link) { - return nil, fmt.Errorf("%s would resolve into the shadowed tree of %s", p, sp) - } - } - if dirs[clean] { - return nil, fmt.Errorf("%s is a parent of a shadowed path", p) - } - if parent := pathDir(clean); dirs[parent] { - return nil, fmt.Errorf("%s would be a symlinked policy root in the view (its parent %s holds a shadowed path)", p, parent) - } - } - return links, nil -} - -// within reports whether slash path p is strictly inside dir. -func within(p, dir string) bool { - return dir == "." || strings.HasPrefix(p, dir+"/") -} - -func pathDir(p string) string { - return filepath.ToSlash(filepath.Dir(filepath.FromSlash(p))) -} - -// viewDirs are the real directories of a view with links: every proper ancestor of a link, -// and the view root ".". -func viewDirs[V any](links map[string]V) map[string]bool { - dirs := map[string]bool{".": true} - for link := range links { - for d := pathDir(link); d != "." && !dirs[d]; d = pathDir(d) { - dirs[d] = true - } - } - return dirs -} - -var safeName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,62}$`) - -// DirFor is the view directory of plugin under root for a view with links over base: -// //. -func DirFor(root, plugin, base string, links map[string]string) string { - name := plugin - if !safeName.MatchString(plugin) { - sum := sha256.Sum256([]byte(plugin)) - name = "p-" + hex.EncodeToString(sum[:8]) - } - h := sha256.New() - _, _ = fmt.Fprintf(h, "%d:%s\n", len(base), base) - keys := slices.Sorted(maps.Keys(links)) - for _, k := range keys { - _, _ = fmt.Fprintf(h, "%d:%s=%d:%s\n", len(k), k, len(links[k]), links[k]) - } - return filepath.Join(root, name, hex.EncodeToString(h.Sum(nil))[:16]) -} - -// Ensure creates the view: its directories, its links, and the mirror links of the base's -// entries. It never removes or changes an entry that is already right, nor a plugin-owned -// one (a real entry where a mirror link would go, see the package doc), so it is safe to -// run while a plugin uses the view. It fails with a *LinkConflictError when a shadow link's -// path holds a real entry. -func (v View) Ensure() error { - if !filepath.IsAbs(v.Dir) || !filepath.IsAbs(v.Base) { - return fmt.Errorf("view %s: the view and base directories must be absolute", v.Dir) - } - dirs := viewDirs(v.Links) - var errs []error - for _, d := range slices.Sorted(maps.Keys(dirs)) { - if err := os.MkdirAll(filepath.Join(v.Dir, filepath.FromSlash(d)), 0o755); err != nil { - return fmt.Errorf("view %s: %w", v.Dir, err) - } - } - for _, link := range slices.Sorted(maps.Keys(v.Links)) { - err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(link)), v.Links[link]) - if errors.Is(err, errNotSymlink) { - err = &LinkConflictError{View: v.Dir, Link: link} - } - if err != nil { - errs = append(errs, err) - } - } - for _, d := range slices.Sorted(maps.Keys(dirs)) { - entries, err := os.ReadDir(filepath.Join(v.Base, filepath.FromSlash(d))) - if errors.Is(err, os.ErrNotExist) { - continue - } - if err != nil { - errs = append(errs, err) - continue - } - for _, e := range entries { - child := e.Name() - if d != "." { - child = d + "/" + child - } - if _, linked := v.Links[child]; linked || dirs[child] { - continue - } - target := filepath.Join(v.Base, filepath.FromSlash(child)) - if contains(target, v.Dir) { - continue // never mirror a directory that holds the view itself - } - err := ensureSymlink(filepath.Join(v.Dir, filepath.FromSlash(child)), target) - if errors.Is(err, errNotSymlink) { - v.pluginOwned(child, target) - continue - } - if err != nil { - errs = append(errs, err) - } - } - } - if err := errors.Join(errs...); err != nil { - return fmt.Errorf("view %s: %w", v.Dir, err) - } - return nil -} - -// pluginOwned warns, once per view and name, that the plugin's own entry name hides the -// working directory's entry target in the view. -func (v View) pluginOwned(name, target string) { - if v.Warn == nil { - return - } - if _, seen := warned.LoadOrStore(v.Dir+"\x00"+name, struct{}{}); seen { - return - } - v.Warn("Plugin created an entry in its working directory (view) that the agent's working directory now also has; "+ - "the plugin keeps its own, and does not see the agent's (plugins should not create files relative to their working directory)", - "view", v.Dir, "path", name, "hidden", target) -} - -// Resolve returns the path the plugin opens for pluginPath, as seen from outside the view: -// relative paths under the view, absolute ones unchanged. -func (v View) Resolve(pluginPath string) string { - if filepath.IsAbs(pluginPath) { - return pluginPath - } - return filepath.Join(v.Dir, pluginPath) -} - -// contains reports whether p is dir or inside it. -func contains(dir, p string) bool { - rel, err := filepath.Rel(dir, p) - if err != nil { - return false - } - return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) -} - -// errNotSymlink is ensureSymlink's error when path holds something that is not a symlink, -// which it never touches. -var errNotSymlink = errors.New("exists and is not a symlink") - -var tmpLinks atomic.Uint64 - -// ensureSymlink makes path a symlink to target: a no-op when it already is, a new link, or -// an atomic replacement (temporary link renamed over it) of a link to something else. It -// never removes or replaces anything but a symlink: a real entry at path is errNotSymlink. -func ensureSymlink(path, target string) error { - cur, err := os.Readlink(path) - switch { - case err == nil && cur == target: - return nil - case err == nil: - // A name of our own, so a failure never removes an entry someone else made. - tmp := fmt.Sprintf("%s.tmp-link-%d-%d", path, os.Getpid(), tmpLinks.Add(1)) - if err := os.Symlink(target, tmp); err != nil { - return err - } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) - return err - } - return nil - } - if _, statErr := os.Lstat(path); statErr == nil { - return fmt.Errorf("%s %w", path, errNotSymlink) - } - if err := os.Symlink(target, path); err != nil { - if !os.IsExist(err) { - return err - } - // Created meanwhile: a link (another Ensure) is fine, anything else is not ours. - if info, statErr := os.Lstat(path); statErr == nil && info.Mode()&os.ModeSymlink == 0 { - return fmt.Errorf("%s %w", path, errNotSymlink) - } - } - return nil -} - -// GC removes the views under root (//) that are not in keep (View.Dir -// values), with every entry in them, plugin-owned ones included. It never follows a link: -// a symlinked plugin directory is skipped, a symlinked view is removed as a link, and -// os.RemoveAll removes the links inside a view, not what they point to. -func GC(root string, keep map[string]struct{}) error { - plugins, err := os.ReadDir(root) - if errors.Is(err, os.ErrNotExist) { - return nil - } - if err != nil { - return err - } - var errs []error - for _, p := range plugins { - if !p.IsDir() { - continue - } - pluginDir := filepath.Join(root, p.Name()) - views, err := os.ReadDir(pluginDir) - if err != nil { - errs = append(errs, err) - continue - } - left := 0 - for _, v := range views { - dir := filepath.Join(pluginDir, v.Name()) - if _, ok := keep[dir]; ok { - left++ - continue - } - err := os.RemoveAll(dir) - if err == nil { - forgetWarnings(dir) - } - errs = append(errs, err) - } - if left == 0 { - _ = os.Remove(pluginDir) - } - } - return errors.Join(errs...) -} - -// forgetWarnings drops the warnings recorded for view dir, which is gone: a view rebuilt at -// the same directory warns again. -func forgetWarnings(dir string) { - warned.Range(func(k, _ any) bool { - if key, _ := k.(string); strings.HasPrefix(key, dir+"\x00") { - warned.Delete(k) - } - return true - }) -} diff --git a/internal/policyview/policyview_test.go b/internal/policyview/policyview_test.go deleted file mode 100644 index 0e68659..0000000 --- a/internal/policyview/policyview_test.go +++ /dev/null @@ -1,335 +0,0 @@ -package policyview - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -const oci = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" - -func TestShadowable(t *testing.T) { - for p, ok := range map[string]bool{ - oci: true, - "./" + oci: true, - "vendor/policies": true, - "policies": false, // no parent to link - "./policies": false, - "/abs/x/policies": false, - "../x/policies": false, - ".compliance-framework/x": false, // not a policies/ tree - "": false, - "a/../b/policies": true, - "a/policies/../x/policies": true, - } { - assert.Equal(t, ok, Shadowable(p) == nil, p) - } -} - -func TestPlan(t *testing.T) { - links, err := Plan([]string{oci}, []string{ - ".compliance-framework/policies/_inline/custom/policies", // a bundle that is not shadowed - ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies", // another tag: a mirrored sibling - "/etc/ccf/policies", // absolute: unaffected - }) - require.NoError(t, err) - assert.Equal(t, map[string]string{oci: ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0"}, links) - - for name, tc := range map[string]struct{ shadowed, others []string }{ - "same path twice": {[]string{oci, "./" + oci}, nil}, - "nested links": {[]string{"a/policies", "a/policies/b/policies"}, nil}, - "other inside a shadow": {[]string{oci}, []string{oci + "/sub"}}, - "other is the shadowed path": {[]string{oci}, []string{oci}}, - "other is a view directory": {[]string{oci}, []string{".compliance-framework/policies"}}, - "other directly in a view dir (leaf would be a mirrored symlink)": {[]string{"vendor/policies"}, []string{"local-policies"}}, - "not shadowable": {[]string{"/abs/policies"}, nil}, - } { - _, err := Plan(tc.shadowed, tc.others) - assert.Error(t, err, name) - } -} - -func TestEnsure(t *testing.T) { - base := t.TempDir() - mk := func(p, content string) { - require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(base, p)), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(base, p), []byte(content), 0o644)) - } - mk(oci+"/vendor.rego", "vendor") - mk(".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies/old.rego", "old") - mk(".compliance-framework/policies/_inline/custom/policies/x.rego", "inline") - mk("config.yml", "cfg") - target := filepath.Join(t.TempDir(), "b", "0123") - require.NoError(t, os.MkdirAll(filepath.Join(target, "policies"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(target, "policies", "bundle.rego"), []byte("bundle"), 0o644)) - - links := map[string]string{LinkOf(oci): target} - v := View{Dir: DirFor(filepath.Join(t.TempDir(), "views"), "ssh", base, links), Base: base, Links: links} - if err := v.Ensure(); err != nil { - if os.IsPermission(err) { - t.Skip(err) - } - require.NoError(t, err) - } - read := func(p string) string { - raw, err := os.ReadFile(filepath.Join(v.Dir, p)) - require.NoError(t, err, p) - return string(raw) - } - assert.Equal(t, "bundle", read(oci+"/bundle.rego"), "the shadowed path is the bundle") - _, err := os.Stat(filepath.Join(v.Dir, oci, "vendor.rego")) - assert.True(t, os.IsNotExist(err), "the vendor tree is hidden") - assert.Equal(t, "old", read(".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.1.0/policies/old.rego")) - assert.Equal(t, "inline", read(".compliance-framework/policies/_inline/custom/policies/x.rego")) - assert.Equal(t, "cfg", read("config.yml"), "other entries of the working directory are mirrored") - info, err := os.Lstat(filepath.Join(v.Dir, oci)) - require.NoError(t, err) - assert.True(t, info.IsDir(), "the leaf is a real directory (OPA does not load a symlinked root)") - - // Idempotent, and picks up entries created since. - mk("later.txt", "later") - require.NoError(t, v.Ensure()) - assert.Equal(t, "later", read("later.txt")) - - // Deterministic directory, and a different target is a different view. - assert.Equal(t, v.Dir, DirFor(filepath.Dir(filepath.Dir(v.Dir)), "ssh", base, links)) - assert.NotEqual(t, v.Dir, DirFor(filepath.Dir(filepath.Dir(v.Dir)), "ssh", base, map[string]string{LinkOf(oci): target + "x"})) - - // GC removes unkept views without following their links. - other := View{Dir: DirFor(filepath.Dir(filepath.Dir(v.Dir)), "ssh", base, map[string]string{LinkOf(oci): target}), Base: base, Links: links} - require.Equal(t, v.Dir, other.Dir) - require.NoError(t, GC(filepath.Dir(filepath.Dir(v.Dir)), map[string]struct{}{})) - _, err = os.Lstat(v.Dir) - assert.True(t, os.IsNotExist(err)) - _, err = os.Stat(filepath.Join(target, "policies", "bundle.rego")) - assert.NoError(t, err, "GC must not follow the view's links") - _, err = os.Stat(filepath.Join(base, oci, "vendor.rego")) - assert.NoError(t, err) -} - -func TestEnsureNeverMirrorsTheViewIntoItself(t *testing.T) { - base := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(base, "vendor", "policies"), 0o755)) - target := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(target, "policies"), 0o755)) - links := map[string]string{"vendor": target} - // The views live under the base's state directory, like the agent's. - v := View{Dir: DirFor(filepath.Join(base, "state", "views"), "ssh", base, links), Base: base, Links: links} - require.NoError(t, v.Ensure()) - _, err := os.Lstat(filepath.Join(v.Dir, "state")) - assert.True(t, os.IsNotExist(err), "the directory holding the view is not mirrored") -} - -// warnings records View.Warn calls. -type warnings struct { - mu sync.Mutex - logs []string -} - -func (w *warnings) warn(msg string, args ...interface{}) { - w.mu.Lock() - defer w.mu.Unlock() - w.logs = append(w.logs, fmt.Sprint(append([]interface{}{msg}, args...)...)) -} - -func (w *warnings) count() int { - w.mu.Lock() - defer w.mu.Unlock() - return len(w.logs) -} - -// shadowedView is a view of base with the bundle tree target linked at vendor/. -func shadowedView(t *testing.T, base string, w *warnings) View { - t.Helper() - require.NoError(t, os.MkdirAll(filepath.Join(base, "vendor", "policies"), 0o755)) - target := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(target, "policies"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(target, "policies", "bundle.rego"), []byte("bundle"), 0o644)) - links := map[string]string{"vendor": target} - v := View{Dir: DirFor(filepath.Join(t.TempDir(), "views"), "custodian", base, links), Base: base, Links: links} - if w != nil { - v.Warn = w.warn - } - if err := v.Ensure(); err != nil { - if os.IsPermission(err) { - t.Skip(err) - } - require.NoError(t, err) - } - return v -} - -// TestEnsure_PluginOwnedEntriesAreKept (rule 1): a plugin that creates a directory relative to -// its working directory (cloud-custodian's debug-standardized-payloads) creates it in its view; -// when the agent's working directory later gets an entry of the same name, the plugin keeps -// its own, Ensure warns once and succeeds. -func TestEnsure_PluginOwnedEntriesAreKept(t *testing.T) { - base := t.TempDir() - w := &warnings{} - v := shadowedView(t, base, w) - - // The plugin creates a directory and a file in its working directory. - owned := filepath.Join(v.Dir, "debug-standardized-payloads") - require.NoError(t, os.MkdirAll(owned, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(owned, "payload.json"), []byte("plugin"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(v.Dir, "out.log"), []byte("plugin log"), 0o644)) - require.NoError(t, v.Ensure(), "entries only the view has are left alone") - assert.Zero(t, w.count()) - - // Later the agent's working directory gets the same names. - require.NoError(t, os.MkdirAll(filepath.Join(base, "debug-standardized-payloads"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(base, "debug-standardized-payloads", "agent.json"), []byte("agent"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(base, "out.log"), []byte("agent log"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(base, "fresh.txt"), []byte("fresh"), 0o644)) - - for range 3 { - require.NoError(t, v.Ensure(), "a plugin-owned entry never fails Ensure") - } - assert.Equal(t, 2, w.count(), "one warning per plugin-owned name, not one per Ensure: %v", w.logs) - assert.Contains(t, strings.Join(w.logs, "\n"), "debug-standardized-payloads") - - info, err := os.Lstat(owned) - require.NoError(t, err) - assert.True(t, info.IsDir() && info.Mode()&os.ModeSymlink == 0, "the plugin's directory is not replaced by a mirror link") - raw, err := os.ReadFile(filepath.Join(owned, "payload.json")) - require.NoError(t, err) - assert.Equal(t, "plugin", string(raw)) - _, err = os.Stat(filepath.Join(owned, "agent.json")) - assert.True(t, os.IsNotExist(err), "the agent's entry is hidden from the plugin") - raw, err = os.ReadFile(filepath.Join(v.Dir, "out.log")) - require.NoError(t, err) - assert.Equal(t, "plugin log", string(raw)) - raw, err = os.ReadFile(filepath.Join(base, "out.log")) - require.NoError(t, err) - assert.Equal(t, "agent log", string(raw), "the agent's entry is untouched") - - // Other entries are still mirrored, and the shadowed path is still the bundle. - raw, err = os.ReadFile(filepath.Join(v.Dir, "fresh.txt")) - require.NoError(t, err) - assert.Equal(t, "fresh", string(raw)) - raw, err = os.ReadFile(filepath.Join(v.Dir, "vendor", "policies", "bundle.rego")) - require.NoError(t, err) - assert.Equal(t, "bundle", string(raw)) - - // A view without a Warn hook behaves the same, silently. - silent := v - silent.Warn = nil - require.NoError(t, silent.Ensure()) -} - -// TestEnsure_PluginOwnedEntryInANestedViewDirectory: the same holds below the view root. -func TestEnsure_PluginOwnedEntryInANestedViewDirectory(t *testing.T) { - base := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(base, "a", "vendor", "policies"), 0o755)) - target := t.TempDir() - links := map[string]string{"a/vendor": target} - w := &warnings{} - v := View{Dir: DirFor(filepath.Join(t.TempDir(), "views"), "p", base, links), Base: base, Links: links, Warn: w.warn} - require.NoError(t, v.Ensure()) - require.NoError(t, os.WriteFile(filepath.Join(v.Dir, "a", "cache"), []byte("plugin"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(base, "a", "cache"), []byte("agent"), 0o644)) - require.NoError(t, v.Ensure()) - require.NoError(t, v.Ensure()) - assert.Equal(t, 1, w.count()) - raw, err := os.ReadFile(filepath.Join(v.Dir, "a", "cache")) - require.NoError(t, err) - assert.Equal(t, "plugin", string(raw)) -} - -// TestEnsure_ConflictAtTheShadowLink: the shadow link is the agent's; a real entry at its -// path (the plugin removed the link and created its own) is a clear error, and Ensure leaves -// the entry alone. -func TestEnsure_ConflictAtTheShadowLink(t *testing.T) { - base := t.TempDir() - v := shadowedView(t, base, &warnings{}) - link := filepath.Join(v.Dir, "vendor") - require.NoError(t, os.Remove(link)) - require.NoError(t, os.MkdirAll(filepath.Join(link, "policies"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(link, "policies", "mine.rego"), []byte("plugin"), 0o644)) - - err := v.Ensure() - var conflict *LinkConflictError - require.ErrorAs(t, err, &conflict) - assert.Equal(t, LinkConflictError{View: v.Dir, Link: "vendor"}, *conflict) - assert.Contains(t, err.Error(), "shadowed policy path") - raw, rerr := os.ReadFile(filepath.Join(link, "policies", "mine.rego")) - require.NoError(t, rerr, "the conflicting entry is not removed") - assert.Equal(t, "plugin", string(raw)) - - // Removing the view (GC, or by hand) rebuilds it cleanly. - require.NoError(t, os.RemoveAll(v.Dir)) - require.NoError(t, v.Ensure()) - raw, rerr = os.ReadFile(filepath.Join(link, "policies", "bundle.rego")) - require.NoError(t, rerr) - assert.Equal(t, "bundle", string(raw)) -} - -// TestEnsure_ReplacesAStaleMirrorLink: a mirror link to something else is the agent's and is -// replaced atomically, leaving no temporary link behind. -func TestEnsure_ReplacesAStaleMirrorLink(t *testing.T) { - base := t.TempDir() - v := shadowedView(t, base, nil) - require.NoError(t, os.WriteFile(filepath.Join(base, "cfg"), []byte("cfg"), 0o644)) - require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(v.Dir, "cfg"))) - require.NoError(t, v.Ensure()) - cur, err := os.Readlink(filepath.Join(v.Dir, "cfg")) - require.NoError(t, err) - assert.Equal(t, filepath.Join(base, "cfg"), cur) - entries, err := os.ReadDir(v.Dir) - require.NoError(t, err) - for _, e := range entries { - assert.NotContains(t, e.Name(), ".tmp-link", "no temporary link left behind") - } -} - -// TestGC_RemovesPluginOwnedEntriesWithoutFollowingLinks: GC removes a whole view, plugin-owned -// entries included, but never what a link (a mirror link, the shadow link, or a link the -// plugin made itself) points to; and a view rebuilt in the same place warns again. -func TestGC_RemovesPluginOwnedEntriesWithoutFollowingLinks(t *testing.T) { - base := t.TempDir() - w := &warnings{} - v := shadowedView(t, base, w) - outside := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(outside, "keep.txt"), []byte("keep"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(base, "agent.txt"), []byte("agent"), 0o644)) - owned := filepath.Join(v.Dir, "debug-standardized-payloads") - require.NoError(t, os.MkdirAll(owned, 0o755)) - require.NoError(t, os.Symlink(outside, filepath.Join(owned, "elsewhere"))) - require.NoError(t, os.MkdirAll(filepath.Join(base, "debug-standardized-payloads"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(base, "debug-standardized-payloads", "agent.json"), []byte("agent"), 0o644)) - require.NoError(t, v.Ensure()) - require.Equal(t, 1, w.count()) - - // A symlinked plugin directory under the root is not followed either. - root := filepath.Dir(filepath.Dir(v.Dir)) - require.NoError(t, os.Symlink(outside, filepath.Join(root, "linked"))) - - require.NoError(t, GC(root, map[string]struct{}{})) - _, err := os.Lstat(v.Dir) - assert.True(t, os.IsNotExist(err), "the view, plugin-owned entries included, is removed") - for _, p := range []string{ - filepath.Join(outside, "keep.txt"), - filepath.Join(base, "agent.txt"), - filepath.Join(base, "debug-standardized-payloads", "agent.json"), - filepath.Join(base, "vendor", "policies"), - filepath.Join(v.Links["vendor"], "policies", "bundle.rego"), - } { - _, err := os.Stat(p) - assert.NoError(t, err, "GC must not follow links: %s", p) - } - _, err = os.Lstat(filepath.Join(root, "linked")) - assert.NoError(t, err, "a symlinked plugin directory is skipped") - - // Rebuilt in the same place, the view warns again for a new plugin-owned entry. - require.NoError(t, v.Ensure()) - require.NoError(t, os.Remove(filepath.Join(v.Dir, "debug-standardized-payloads"))) - require.NoError(t, os.MkdirAll(owned, 0o755)) - require.NoError(t, v.Ensure()) - assert.Equal(t, 2, w.count()) -} diff --git a/policy-manager/evidence_seed_test.go b/policy-manager/evidence_seed_test.go index 7b65ea9..625c4bc 100644 --- a/policy-manager/evidence_seed_test.go +++ b/policy-manager/evidence_seed_test.go @@ -8,14 +8,15 @@ import ( ) // TestEvidenceSeedIsUnchanged pins the evidence UUIDs plugins in the field produce for -// several label and path combinations. Every evidence stream depends on them (path shadowing -// keeps vendor streams only because the seed is the path string): they must never change. +// 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" - inlinePath = "/app/.compliance-framework/state/local-dev/inline/test/current/bundle" + // 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} @@ -27,7 +28,7 @@ func TestEvidenceSeedIsUnchanged(t *testing.T) { 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(inlinePath), inlinePath + "/ssh/banner.rego", "data.compliance_framework.banner", "966b3869-b39b-4b2b-9257-7f475e3bb54c"}, + {"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"}, diff --git a/policy-manager/policy-manager.go b/policy-manager/policy-manager.go index 3507d50..a583108 100644 --- a/policy-manager/policy-manager.go +++ b/policy-manager/policy-manager.go @@ -34,26 +34,6 @@ func New(ctx context.Context, logger hclog.Logger, policyPath string, policyData } } -// NewWithEvaluator wraps an evaluator built by the caller, for example one restricted to -// policyeval.SandboxCapabilities. The agent uses it to dry-run inline bundles exactly the way -// plugins evaluate them. -func NewWithEvaluator(logger hclog.Logger, evaluator *policyeval.Evaluator) *PolicyManager { - return &PolicyManager{logger: logger, evaluator: evaluator} -} - -// RiskTemplateError is a failure to read the risk_templates of one policy package. -type RiskTemplateError struct { - Package string // without the leading "data." - File string - Err error -} - -func (e *RiskTemplateError) Error() string { - return fmt.Sprintf("risk_templates of package %s (%s): %v", e.Package, e.File, e.Err) -} - -func (e *RiskTemplateError) Unwrap() error { return e.Err } - func (pm *PolicyManager) prepareForEval(ctx context.Context, regoArgs ...func(r *rego.Rego)) (rego.PreparedEvalQuery, error) { return pm.evaluator.PrepareForEval(ctx, regoArgs...) } @@ -286,7 +266,7 @@ func (pm *PolicyManager) GetRiskTemplates(ctx context.Context) (map[string][]*pr riskTemplates, err := pm.evaluateRiskTemplates(ctx, policy) if err != nil { - return nil, &RiskTemplateError{Package: purePackage, File: policy.File, Err: err} + return nil, err } if _, exists := allTemplates[purePackage]; !exists { @@ -297,12 +277,12 @@ func (pm *PolicyManager) GetRiskTemplates(ctx context.Context) (map[string][]*pr for _, riskTemplate := range riskTemplates { temp := &RiskTemplate{} if err := mapstructure.Decode(riskTemplate, temp); err != nil { - return nil, &RiskTemplateError{Package: purePackage, File: policy.File, Err: err} + return nil, err } template, err := newProtoRiskTemplate(policy, temp) if err != nil { - return nil, &RiskTemplateError{Package: purePackage, File: policy.File, Err: err} + return nil, err } moduleTemplates = append(moduleTemplates, template) diff --git a/runner/policy_artifacts.go b/runner/policy_artifacts.go index 0fc4990..93f3140 100644 --- a/runner/policy_artifacts.go +++ b/runner/policy_artifacts.go @@ -165,23 +165,21 @@ func evaluationKey(e *proto.PolicyEvaluation) string { return "content:" + hex.EncodeToString(h.Sum(nil)) } -// storeEvaluation uploads what one evaluation used. The bundle is read from the directory -// the plugin's policy path resolved to when the helper was created (see WithPolicyPaths), so -// it is the tree the run evaluated even if the path is a symlink swapped afterwards. +// storeEvaluation uploads what one evaluation used: the policy bundle at the policy path the +// plugin was given (see WithPolicyPaths), its input and its policy data. func (h *apiHelper) storeEvaluation(ctx context.Context, evaluation *proto.PolicyEvaluation) (*types.PolicyArtifacts, error) { if h.artifacts.unsupported() { return nil, ErrArtifactsUnsupported } policyPath := filepath.Clean(evaluation.GetPolicyPath()) - dir, ok := h.policyPaths[policyPath] - if !ok { + if _, ok := h.policyPaths[policyPath]; !ok { return nil, fmt.Errorf("policy path %q is not one of the plugin's policy bundles", evaluation.GetPolicyPath()) } if len(evaluation.GetInput()) == 0 { return nil, errors.New("the evaluation has no input data") } - bundle, err := policytree.TarDirectory(dir) + bundle, err := policytree.TarDirectory(policyPath) if err != nil { return nil, fmt.Errorf("package policy bundle: %w", err) } diff --git a/runner/policy_artifacts_test.go b/runner/policy_artifacts_test.go index 21b83b4..7527446 100644 --- a/runner/policy_artifacts_test.go +++ b/runner/policy_artifacts_test.go @@ -12,7 +12,6 @@ import ( "net/http/httptest" "os" "path/filepath" - "runtime" "strings" "sync" "testing" @@ -387,35 +386,3 @@ func TestSharedUploaderUploadsOncePerAPI(t *testing.T) { send("http://other.example", "three") assert.Len(t, api.uploads, 4) } - -// TestPolicyPathIsResolvedWhenTheHelperIsCreated: the artifact of an inline bundle's stable -// path is the tree it pointed to when the run started, even if the agent swaps it later. -func TestPolicyPathIsResolvedWhenTheHelperIsCreated(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks need privileges on Windows") - } - base := t.TempDir() - first, second := filepath.Join(base, "v1"), filepath.Join(base, "v2") - for dir, title := range map[string]string{first: "one", second: "two"} { - require.NoError(t, os.MkdirAll(dir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "p.rego"), []byte("package compliance_framework.p\n\ntitle := \""+title+"\"\n"), 0o644)) - } - require.NoError(t, os.Symlink("v1", filepath.Join(base, "current"))) - stable := filepath.Join(base, "current", ".") - - api := &fakeAPI{} - helper := newTestHelper(t, api, stable) - - // The agent swaps the link after the run started. - require.NoError(t, os.Symlink("v2", filepath.Join(base, "next"))) - require.NoError(t, os.Rename(filepath.Join(base, "next"), filepath.Join(base, "current"))) - - require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ - evidenceFor("one", &proto.PolicyEvaluation{PolicyPath: stable, Input: []byte(`{}`)}), - })) - tarball, err := policytree.TarDirectory(first) - require.NoError(t, err) - sum := sha256.Sum256(tarball) - refs := artifactsOf(api)["one"].(map[string]any) - assert.Equal(t, "sha256:"+hex.EncodeToString(sum[:]), refs["bundle-digest"], "the bundle must be the tree the run started with") -} diff --git a/runner/result.go b/runner/result.go index 768cce5..ca48442 100644 --- a/runner/result.go +++ b/runner/result.go @@ -17,13 +17,8 @@ type apiHelper struct { agentLabels map[string]string pluginName string artifacts *ArtifactEndpoint - // policyPaths maps each policy path the plugin was given (cleaned) to the directory it - // resolved to when the helper was created. - policyPaths map[string]string - // rawPolicyPaths and policyRoot are what WithPolicyPaths and WithPolicyRoot set; - // NewApiHelper resolves them into policyPaths. - rawPolicyPaths []string - policyRoot string + // policyPaths are the policy bundle paths the plugin was given (cleaned). + policyPaths map[string]struct{} // evidenceProps are appended to every evidence the plugin creates. evidenceProps []types.Property @@ -44,11 +39,6 @@ type Source struct { // Digest is the registry digest the OCI reference resolved to when the agent downloaded // it, or for a local plugin binary its SHA-256. Empty when not known. Digest string - // BundleArtifact marks a policy bundle whose digest is the artifact digest the API - // assigned to the bundle: an inline bundle, which has no registry digest. Evidence then - // records the digest of the bundle its evaluation stored, and Digest (the bundle's tree - // digest) only when the bundle could not be stored. - BundleArtifact bool } // Evidence props recording where the plugin and policy bundle came from. The agent owns @@ -86,37 +76,12 @@ func WithSources(plugin Source, policies map[string]Source) ApiHelperOption { type ApiHelperOption func(*apiHelper) // WithPolicyPaths sets the policy bundle paths the plugin was given. The agent uploads only -// these bundles as artifacts, so a plugin cannot make the agent read anything else. Each -// path is resolved now: an inline bundle's stable path is a symlink the agent swaps between -// configuration runs, and the artifact must be the tree this run evaluated. +// these bundles as artifacts, so a plugin cannot make the agent read anything else. func WithPolicyPaths(paths []string) ApiHelperOption { return func(h *apiHelper) { - h.rawPolicyPaths = append(h.rawPolicyPaths, paths...) - } -} - -// WithPolicyRoot sets the working directory the plugin runs in (its view, when it receives -// a shadowed inline bundle): relative policy paths resolve against it, as they do for the -// plugin, so the agent reads the tree the plugin evaluated. Empty means the agent's own -// working directory. -func WithPolicyRoot(dir string) ApiHelperOption { - return func(h *apiHelper) { - h.policyRoot = dir - } -} - -// resolvePolicyPaths fills policyPaths from rawPolicyPaths and policyRoot. -func (h *apiHelper) resolvePolicyPaths() { - for _, path := range h.rawPolicyPaths { - clean := filepath.Clean(path) - dir := clean - if h.policyRoot != "" && !filepath.IsAbs(clean) { - dir = filepath.Join(h.policyRoot, clean) - } - if resolved, err := filepath.EvalSymlinks(dir); err == nil { - dir = resolved + for _, path := range paths { + h.policyPaths[filepath.Clean(path)] = struct{}{} } - h.policyPaths[clean] = dir } } @@ -147,14 +112,13 @@ func NewApiHelper(logger hclog.Logger, client *sdk.Client, agentLabels map[strin client: client, agentLabels: agentLabels, pluginName: pluginName, - policyPaths: map[string]string{}, + policyPaths: map[string]struct{}{}, policySources: map[string]Source{}, } for _, opt := range opts { opt(h) } - h.resolvePolicyPaths() if h.uploader == nil { h.uploader = NewArtifactUploader() } @@ -277,7 +241,7 @@ func (h *apiHelper) toSdk(e *proto.Evidence, outcome evaluationOutcome) types.Ev } } if outcome.policyPath != "" { - evid.Props = appendSource(evid.Props, h.policySource(outcome), PropPolicySource, PropPolicyDigest) + evid.Props = appendSource(evid.Props, h.policySources[filepath.Clean(outcome.policyPath)], PropPolicySource, PropPolicyDigest) } labels := make(map[string]string) for k, v := range h.agentLabels { @@ -396,17 +360,6 @@ func withPluginSelectorLabel(labels []types.SubjectTemplateSelectorLabel, plugin }) } -// policySource is the source of the policy bundle an evaluation used, keyed by the path the -// plugin was given. For an inline bundle the digest is the artifact digest of the bundle the -// evaluation stored, when it was stored. -func (h *apiHelper) policySource(outcome evaluationOutcome) Source { - source := h.policySources[filepath.Clean(outcome.policyPath)] - if source.BundleArtifact && outcome.refs != nil && outcome.refs.BundleDigest != "" { - source.Digest = outcome.refs.BundleDigest - } - return source -} - func appendSource(props []types.Property, source Source, referenceProp, digestProp string) []types.Property { if source.Reference == "" { return props diff --git a/runner/shadow_test.go b/runner/shadow_test.go deleted file mode 100644 index b6d2866..0000000 --- a/runner/shadow_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package runner - -import ( - "context" - "net/http/httptest" - "os" - "path/filepath" - "testing" - - "github.com/compliance-framework/agent/internal/policytree" - "github.com/compliance-framework/agent/runner/proto" - "github.com/compliance-framework/api/sdk" - "github.com/hashicorp/go-hclog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// Path shadowing: a plugin running in a view receives a relative path that, in the view, -// resolves to an inline bundle while, from the agent's working directory, it is the vendor -// source. The agent must read what the plugin evaluated. - -const shadowedPath = ".compliance-framework/policies/vendor/policies/v1/policies" - -// shadowFixture returns a base (the agent's working directory, with the vendor tree at -// shadowedPath) and a view in which shadowedPath is the bundle's tree. -func shadowFixture(t *testing.T) (base, view, bundleTree string) { - t.Helper() - base = t.TempDir() - vendor := filepath.Join(base, shadowedPath) - require.NoError(t, os.MkdirAll(vendor, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(vendor, "a.rego"), []byte("package compliance_framework.a\n\ntitle := \"vendor\"\n"), 0o644)) - - store := filepath.Join(t.TempDir(), "inline", "b", "0123") - bundleTree = filepath.Join(store, "policies") - require.NoError(t, os.MkdirAll(bundleTree, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(bundleTree, "a.rego"), []byte("package compliance_framework.a\n\ntitle := \"inline\"\n"), 0o644)) - - view = t.TempDir() - link := filepath.Join(view, filepath.Dir(shadowedPath)) - require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o755)) - if err := os.Symlink(store, link); err != nil { - t.Skip("symlinks are not supported here") - } - t.Chdir(base) - return base, view, bundleTree -} - -func TestPolicyRootResolvesRelativePathsInTheView(t *testing.T) { - base, view, bundleTree := shadowFixture(t) - abs := t.TempDir() - - h := NewApiHelper(hclog.NewNullLogger(), nil, nil, "ssh", WithPolicyRoot(view), WithPolicyPaths([]string{shadowedPath, abs})) - want, err := filepath.EvalSymlinks(bundleTree) - require.NoError(t, err) - assert.Equal(t, want, h.policyPaths[shadowedPath], "the agent reads the tree the plugin evaluated, not the vendor's") - wantAbs, _ := filepath.EvalSymlinks(abs) - assert.Equal(t, wantAbs, h.policyPaths[abs], "absolute paths are unaffected") - - inline, err := policytree.TarDirectory(h.policyPaths[shadowedPath]) - require.NoError(t, err) - vendor, err := policytree.TarDirectory(filepath.Join(base, shadowedPath)) - require.NoError(t, err) - assert.NotEqual(t, vendor, inline) - - // Without a root (no view) the agent's working directory is used, as before. - plain := NewApiHelper(hclog.NewNullLogger(), nil, nil, "ssh", WithPolicyPaths([]string{shadowedPath})) - assert.Equal(t, shadowedPath, plain.policyPaths[shadowedPath]) -} - -// TestPolicySourceFromThePolicyPathLabel: a plugin built on an agent library without policy -// evaluations still labels its evidence with _policy_path; the agent records the source of -// that path, which for a shadowed path is the inline bundle. -func TestPolicySourceFromThePolicyPathLabel(t *testing.T) { - _, view, _ := shadowFixture(t) - api := &fakeAPI{} - server := httptest.NewServer(api) - t.Cleanup(server.Close) - client := sdk.NewClient(server.Client(), &sdk.Config{BaseURL: server.URL}) - inline := Source{Reference: "inline:b", Digest: "tree:sha256:abcd", BundleArtifact: true} - h := NewApiHelper(hclog.NewNullLogger(), client, nil, "ssh", - WithPolicyRoot(view), WithPolicyPaths([]string{shadowedPath}), - WithSources(testPlugin, map[string]Source{shadowedPath: inline})) - - labelled := &proto.Evidence{UUID: "11111111-1111-1111-1111-111111111111", Title: "old plugin", Labels: map[string]string{LabelPolicyPath: shadowedPath}} - unknown := &proto.Evidence{UUID: "11111111-1111-1111-1111-111111111112", Title: "other path", Labels: map[string]string{LabelPolicyPath: "elsewhere"}} - require.NoError(t, h.CreateEvidence(context.Background(), []*proto.Evidence{labelled, unknown})) - - props := sentProps(api) - assert.Equal(t, "inline:b", props["old plugin"][PropPolicySource]) - assert.Equal(t, "tree:sha256:abcd", props["old plugin"][PropPolicyDigest], "no artifact was stored: the tree digest") - assert.NotContains(t, props["other path"], PropPolicySource, "a path the plugin was not given records nothing") -} diff --git a/runner/source_props_test.go b/runner/source_props_test.go index 78024aa..c22c6f0 100644 --- a/runner/source_props_test.go +++ b/runner/source_props_test.go @@ -3,9 +3,6 @@ package runner import ( "context" "net/http" - "os" - "path/filepath" - "runtime" "testing" "github.com/compliance-framework/agent/runner/proto" @@ -154,46 +151,23 @@ func TestSourceWithoutDigestRecordsOnlyTheReference(t *testing.T) { assert.NotContains(t, props, PropPolicyDigest) } -// TestInlineBundleRecordsItsEntryAndArtifactDigest: an inline bundle is keyed by the stable -// path the plugin receives (R67), a symlink to the bundle's tree, and records its entry and -// the artifact digest of the bundle the evaluation stored (design §13.4). -func TestInlineBundleRecordsItsEntryAndArtifactDigest(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks need privileges on Windows") - } - base := t.TempDir() - tree := filepath.Join(base, "0123abcd") - require.NoError(t, os.MkdirAll(filepath.Join(tree, "bundle"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(tree, "bundle", "p.rego"), []byte("package compliance_framework.p\n\ntitle := \"p\"\n"), 0o644)) - require.NoError(t, os.Symlink("0123abcd", filepath.Join(base, "current"))) - stable := filepath.Join(base, "current", "bundle") - - inline := Source{Reference: "inline:ssh", Digest: "tree:sha256:3333", BundleArtifact: true} - api := &fakeAPI{} - helper := newTestHelper(t, api, stable) - WithSources(testPlugin, map[string]Source{stable: inline})(helper) - - require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ - evidenceFor("inline", &proto.PolicyEvaluation{PolicyPath: stable, Input: []byte(`{}`)}), - })) - props := sentProps(api)["inline"] - refs := artifactsOf(api)["inline"].(map[string]any) - assert.Equal(t, "inline:ssh", props[PropPolicySource]) - assert.Equal(t, refs["bundle-digest"], props[PropPolicyDigest], "the bundle's artifact digest") -} - -func TestInlineBundleFallsBackToItsTreeDigest(t *testing.T) { +// 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") - inline := Source{Reference: "inline:ssh", Digest: "tree:sha256:3333", BundleArtifact: true} - api := &fakeAPI{artifactStatuses: []int{http.StatusRequestEntityTooLarge}} + api := &fakeAPI{} helper := newTestHelper(t, api, bundle) - WithSources(testPlugin, map[string]Source{bundle: inline})(helper) + WithSources(testPlugin, map[string]Source{bundle: testPolicy})(helper) - require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ - evidenceFor("too large", &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), - })) - props := sentProps(api)["too large"] - assert.Nil(t, artifactsOf(api)["too large"]) - assert.Equal(t, "inline:ssh", props[PropPolicySource]) - assert.Equal(t, "tree:sha256:3333", props[PropPolicyDigest]) + 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") } From f5521fbd56e058f3d99edbd0ed6b21181fabbb6d Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 08:48:50 -0300 Subject: [PATCH 40/47] refactor!: drop policy errors and the plugin-lib compat gate Nothing produces policy errors any more, so the plumbing goes: applyError, the candidate's policy warnings, the report's policy-errors and the rejected record's policy_errors in the remote config cache. pluginlib keeps only the build-info version reader the plugins report uses; MinViolationSet and AtLeast (and golang.org/x/mod) are removed. Co-Authored-By: Claude Opus 5.5 --- cmd/reconciler.go | 26 +++++++------------ cmd/report.go | 2 -- go.mod | 1 - internal/agentstate/cache.go | 7 +++--- internal/pluginlib/pluginlib.go | 37 +++------------------------- internal/pluginlib/pluginlib_test.go | 28 --------------------- 6 files changed, 15 insertions(+), 86 deletions(-) diff --git a/cmd/reconciler.go b/cmd/reconciler.go index b00fb07..add4909 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -46,10 +46,6 @@ var ( prepareNetworkTimeout = 5 * time.Minute ) -// maxRememberedPolicyErrors bounds the policy errors persisted with a rejection (the cache is -// rewritten on every fetch); the report after a restart lists at most this many. -const maxRememberedPolicyErrors = 100 - // 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. @@ -64,9 +60,8 @@ type candidate struct { identity string bundles []agentconfig.PolicyBundleReport // trees are the policy trees bundles describe, uploaded as artifacts for the report (R62). - trees []artifactTree - warnings []agentconfig.FieldError // R34 file-origin warnings - policyWarnings []agentconfig.PolicyError // G3b severity=warning + trees []artifactTree + warnings []agentconfig.FieldError // R34 file-origin warnings // plugins are the runtime's plugins with their agent library versions (R76). plugins []agentconfig.PluginReport } @@ -83,11 +78,10 @@ func (c *candidate) appliedRevision() *int64 { // 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 - PolicyErrors []agentconfig.PolicyError + 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 @@ -486,10 +480,9 @@ func (rc *reconciler) rememberedRejection(mode string) *applyError { rev := f.Revision rc.attempted = &rev aerr := &applyError{ - Status: r.Status, - Reason: r.Reason, - Unsafe: slices.Clone(r.Unsafe), - PolicyErrors: slices.Clone(r.PolicyErrors), + Status: r.Status, + Reason: r.Reason, + Unsafe: slices.Clone(r.Unsafe), } if r.Error != "" { aerr.Err = errors.New(r.Error) @@ -518,7 +511,6 @@ func (rc *reconciler) recordFailure(target *agentstate.OverlayRecord, aerr *appl Reason: aerr.Reason, Error: msg, Unsafe: aerr.Unsafe, - PolicyErrors: aerr.PolicyErrors[:min(len(aerr.PolicyErrors), maxRememberedPolicyErrors)], } rc.saveCache() return diff --git a/cmd/report.go b/cmd/report.go index d627de0..92db86b 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -149,7 +149,6 @@ func (rc *reconciler) buildReport(active *candidate, outcome *applyError, rcfg a RemoteConfig: &rcfg, Plugins: active.plugins, } - report.PolicyErrors = append(report.PolicyErrors, active.policyWarnings...) switch { case !isApplyMode(rcfg.Mode): report.Status = agentconfig.StatusNotApplicable @@ -163,7 +162,6 @@ func (rc *reconciler) buildReport(active *candidate, outcome *applyError, rcfg a } report.Error = &msg report.Unsafe = outcome.Unsafe - report.PolicyErrors = append(append([]agentconfig.PolicyError(nil), outcome.PolicyErrors...), report.PolicyErrors...) default: report.Status = agentconfig.StatusApplied } diff --git a/go.mod b/go.mod index 9d71656..dabbe16 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - golang.org/x/mod v0.36.0 golang.org/x/sync v0.21.0 google.golang.org/grpc v1.79.3 google.golang.org/protobuf v1.36.11 diff --git a/internal/agentstate/cache.go b/internal/agentstate/cache.go index 3258bb6..425a32f 100644 --- a/internal/agentstate/cache.go +++ b/internal/agentstate/cache.go @@ -53,10 +53,9 @@ type RejectedRecord struct { Status string `json:"status"` Reason string `json:"reason"` Error string `json:"error"` - // Unsafe and PolicyErrors complete the outcome re-reported after a restart. They are - // optional: caches written before they existed load (and checksum) unchanged. - Unsafe []agentconfig.Change `json:"unsafe,omitempty"` - PolicyErrors []agentconfig.PolicyError `json:"policy_errors,omitempty"` + // 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). diff --git a/internal/pluginlib/pluginlib.go b/internal/pluginlib/pluginlib.go index 871352d..6dac3d4 100644 --- a/internal/pluginlib/pluginlib.go +++ b/internal/pluginlib/pluginlib.go @@ -1,8 +1,8 @@ // Package pluginlib reads which version of this module (the agent library) a plugin binary -// was built with, and decides what the plugin supports (R76). +// was built with (R76). The config report lists it per plugin as diagnostics. // -// Plugins evaluate policies with the policy-manager they embed, so what a plugin can do with -// a policy depends on the agent library it was compiled against, not on the running agent. +// 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 @@ -12,22 +12,11 @@ import ( "os" "sync" "time" - - "golang.org/x/mod/module" - "golang.org/x/mod/semver" ) // AgentModule is the module path plugins import for runner and policy-manager. const AgentModule = "github.com/compliance-framework/agent" -// Minimum agent library versions. -const ( - // MinViolationSet is the first agent library whose policy-manager accepts violation as a - // set (`violation contains {...}`, agent#86). Older plugins expect an object - // (`violation[{...}] if { ... }`) and crash on a set. - MinViolationSet = "v0.7.1" -) - // 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 @@ -49,26 +38,6 @@ func Version(path string) (string, error) { return "", nil } -// AtLeast reports whether version is min or later. known is false when version is not a -// version that can be compared ("" for unknown, "(devel)", a pseudo-version with no tag -// before it): then ok is false too. A pseudo-version counts as the tagged version it was -// built after (v0.7.2-0.2026…-abc is v0.7.1 plus unreleased commits, which may not include -// what min added). Versions compare as semver, so pre-releases of min are older than min -// (v0.7.1-rc1 < v0.7.1): a release candidate cut before a feature landed does not have it. -func AtLeast(version, min string) (ok, known bool) { - base := version - if module.IsPseudoVersion(version) { - var err error - if base, err = module.PseudoVersionBase(version); err != nil || base == "" { - return false, false - } - } - if !semver.IsValid(base) { - return false, false - } - return semver.Compare(base, min) >= 0, true -} - // 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. diff --git a/internal/pluginlib/pluginlib_test.go b/internal/pluginlib/pluginlib_test.go index efda579..c2b1777 100644 --- a/internal/pluginlib/pluginlib_test.go +++ b/internal/pluginlib/pluginlib_test.go @@ -9,34 +9,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestAtLeast(t *testing.T) { - cases := []struct { - version, min string - ok, known bool - }{ - {"v0.7.1", MinViolationSet, true, true}, - {"v0.7.2", MinViolationSet, true, true}, - {"v0.7.0", MinViolationSet, false, true}, - {"v0.1.9", MinViolationSet, false, true}, - {"v0.1.9-0.20250101000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.1.8 - {"v0.7.2-0.20260601000000-abcdefabcdef", MinViolationSet, true, true}, // after v0.7.1 - {"v0.7.1-0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.0, before v0.7.1 - {"v0.7.1-rc1", MinViolationSet, false, true}, // semver: before v0.7.1 - {"v0.7.1-rc1.0.20260501000000-abcdefabcdef", MinViolationSet, false, true}, // after v0.7.1-rc1 - {"v0.10.0", MinViolationSet, true, true}, - {"v1.0.0", MinViolationSet, true, true}, - {"", MinViolationSet, false, false}, - {"(devel)", MinViolationSet, false, false}, - {"v0.0.0-20261001000000-abcdefabcdef", MinViolationSet, false, false}, // no tag before it - {"garbage", MinViolationSet, false, false}, - } - for _, tc := range cases { - ok, known := AtLeast(tc.version, tc.min) - assert.Equal(t, tc.ok, ok, "%s >= %s", tc.version, tc.min) - assert.Equal(t, tc.known, known, "%s known", tc.version) - } -} - 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() From 9c85d14f89521ffc6369ebecf515ac59129c299d Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 08:49:33 -0300 Subject: [PATCH 41/47] chore: pin api cce6baf The API drops inline policy bundles, policy errors and the policy contract check from pkg/agentconfig and pkg/policyeval. An overlay that still sets policy_bundles is now rejected as unknown-field. Co-Authored-By: Claude Opus 5.5 --- cmd/remote_test.go | 1 + go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/remote_test.go b/cmd/remote_test.go index a9f5a85..3d4aea7 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -580,6 +580,7 @@ func TestApply_ClassifyGate(t *testing.T) { {"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) { diff --git a/go.mod b/go.mod index dabbe16..f5506e1 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.1-0.20261002091123-e69e2862d3ba + github.com/compliance-framework/api v0.20.1-0.20261002112713-cce6baf38414 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 diff --git a/go.sum b/go.sum index a2f0293..9383c38 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.1-0.20261002091123-e69e2862d3ba h1:oDQgc1Ymyqdb5Q0pv3N/2dXjzzSLyUpR4vlHbwGNc6Y= -github.com/compliance-framework/api v0.20.1-0.20261002091123-e69e2862d3ba/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +github.com/compliance-framework/api v0.20.1-0.20261002112713-cce6baf38414 h1:bGCccOmZhFTQhqgCldPy7U/r1w3k4RqEnKd0L+Mpit8= +github.com/compliance-framework/api v0.20.1-0.20261002112713-cce6baf38414/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= From 8efe38f1a95829f5873b677735a84740335010bc Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 08:51:21 -0300 Subject: [PATCH 42/47] docs: scope remote configuration to the overlay, safeguards and reporting Remove the inline policy bundle, path shadowing, policy identity, plugin compatibility and policy error content from the configuration guide, ADR 0003, policy_artifacts.md, AGENTS.md, the README and the service guide. Document what stays: the report's policy-bundles inventory with artifact digests ("Sources for the UI"), plugins[].lib-version as diagnostics, and the _policy_path label fallback for _policy_source. ADR 0003 records that inline bundles are out of scope. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 +- README.md | 5 +- docs/adr/0003-remote-config-overlay.md | 118 +++------------ docs/configuration.md | 200 ++++--------------------- docs/policy_artifacts.md | 20 +-- docs/running_as_a_service.md | 4 +- 6 files changed, 69 insertions(+), 288 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a1f2f67..4d2326f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,12 +115,10 @@ change here must keep working with them. - **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: path shadowing keeps vendor streams only because the seed is the path string. - The golden test in `policy-manager/evidence_seed_test.go` pins the UUIDs. -- **Plugin library checks.** Inline policies work with every plugin build (path shadowing keeps vendor evidence - streams). Only one thing depends on the plugin's agent library (`internal/pluginlib`): an overlay-introduced - set-form `violation contains` is rejected below `MinViolationSet` (v0.7.1). Versions compare as semver, so - pre-releases of a minimum are older. + 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/README.md b/README.md index d9a19b0..9b1580f 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,10 @@ 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. Plugins never receive `CCF_API_AUTH_*` variables. -### Remote configuration, inline policies and state +### Remote configuration and state With `api.auth` credentials the agent reports its configuration to the API and can apply a configuration overlay -stored there (`remote_config`), including inline policy bundles (`policy_bundles`) and `${env:NAME}` placeholders in -plugin config. Each instance keeps a stable ID and a cache in a state directory (`--state-dir` / `CCF_STATE_DIR`; +stored there (`remote_config`), including `${env:NAME}` placeholders in plugin config. 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). diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index b405fcd..59b69e6 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -6,12 +6,12 @@ ## Context Operators want to see the configuration each agent is running and to change it from the API (new schedules, plugin -config, temporary inline policies) without logging in to every host. The local config file must stay the bootstrap and +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` and `api/pkg/agentconfig/regocheck`), so -the agent, the API's validation and the UI preview classify and validate changes the same way. +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 @@ -28,8 +28,7 @@ The file is decoded through viper's weak decoder exactly as before (R51). Only a ### 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, inline bundle materialization and -checks, and every download (`Prefetch`). Only then is the running configuration cancelled. Any failure leaves the +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 @@ -37,7 +36,7 @@ the previous configuration and that candidate (overlay or file-only) enters the 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. -Materialized inline bundles are garbage-collected at startup and after every swap. Startup tries the fetched overlay, +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). @@ -48,98 +47,29 @@ The API validates and previews, but the agent classifies every revision against sources, `${env:CCF_API_AUTH_*}`) reject the whole revision in every mode (R23). Nothing touches the network before the gate passes. -### Per-path compile unit and transitive denied builtins - -Plugins evaluate every policy path as its own bundle, so the agent checks an inline bundle per (plugin, policy path) -through the same `policyeval.NewFromBundlePath` prepare path `policy-manager` uses (R21). Cross-bundle imports are -unsupported. The API's Rego check is parse-level; the agent additionally walks every rule reachable from the bundle's -authored rules in the compiled rule graph and rejects any `policyeval.DeniedBuiltins` reference (`http.send`, -`net.lookup_ip_addr`, `opa.runtime`), including through vendor helpers and `with ... as http.send` (R19, R20). -When the walk finds one, the bundle's Rego tests are not run. The tests themselves run sandboxed: they compile under -`policyeval.SandboxCapabilities` with every denied builtin rewritten to a stub that errors, so no denied builtin ever -executes on the agent host while a revision is checked (D17); a vendor test that needs one simply fails (a warning). - -Residual risk (R20): a vendor rule that already calls `http.send` with a URL taken from `data` makes `policy_data` -edits to that plugin effectively able to direct its requests. Eval-time capabilities are a follow-up. - -### Policy contract: the agent is authoritative (R63, R65) - -The API's `regocheck` runs `policyeval.CheckContract` on the authored modules only (it lacks the extends trees). The -agent, after the compile and the tests pass, adds what needs the whole tree: the static check on the vendor-only -packages (warnings, never re-run on authored modules), and a dry run on an empty input through `policyeval.Execute` -and `policy-manager`'s `GetRiskTemplates`, sandboxed like the tests. Decode errors and `Result.Issues` become located -`PolicyError`s with the contract codes: errors for packages that contain an authored non-test module, warnings for -vendor-only packages; conflicts that only show on `{}` and input-dependent titles are warnings. A package that fails to evaluate is -left out of the next attempt, so one broken package does not hide the others. A compile error in a vendor file whose -package an authored module also defines carries an override hint (R65). The per-plugin results are de-duplicated before they are reported. - -### Evidence identity across a plugin's paths (R66, R75) - -`policy-manager` seeds evidence UUIDs from the policy's package, file and plugin path; we never change that seed, so -the identity of an existing stream never changes. The agent checks identity statically over every module of every -policy path of each plugin that uses an inline bundle: the same identity from two paths (equal seeds, or the same -package and bundle-relative file) is `duplicate-policy-identity`, an error when the overlay introduces it (it gives -the plugin a policy entry the file does not, or changes a bundle involved) and a warning otherwise (R34), so an -overlay never fails on the file's own duplicates; what is left of R66 (the same package with different identities) -stays a warning. For an `extends` bundle, an override that changes the `package` of the vendor module it replaces is -`policy-package-changed` (a warning). - -### Path shadowing (R83, R88) - -A plugin seeds evidence UUIDs from the policy path string it receives, so a bundle that extends a relative source is -given to plugins at the source's own path, and the plugin runs with a per-plugin view directory as its working -directory (`internal/policyview`), in which that path's parent links to the bundle's tree and everything else mirrors -the agent's working directory. Evidence identity is then the vendor's by construction, for every plugin build. The -agent resolves every relative policy path of such a plugin through its view (artifact uploads, source props). We -rejected declaring the identity in the policy instead (an authored `policy_id` replacing the location in the seed): -it only works for plugins rebuilt on a newer agent library, while shadowing works for every build. Where a view cannot represent the paths (absolute `extends`, a plugin loading the source and the bundle -together, no symlinks) the bundle is given to plugins at its own `_inline` path and its modules start path-based -streams; each plugin that uses it gets a `policy-stream-forked` warning with the reason. -Risk: a plugin that relies on its working directory sees the view (mirrored, so reads and writes inside existing -directories still reach the agent's; new top-level files stay in the view). -Plugin contract (rule 1): plugins must not rely on creating new files relative to their working directory (use -absolute paths or `os.TempDir()`); such entries stay in the plugin's view and are removed with it. A real -(non-symlink) entry in a view is plugin-owned: when the agent's working directory later gets the same name, the -view keeps the plugin's entry instead of mirroring the agent's, warns once per view and name, and never fails a run -or an activation. Only the shadow link is agent-owned: a real entry at its path is a conflict that fails that -plugin's runs with a clear error and is not removed. Activation only logs it: views are content-addressed and -survive restarts, so failing the configuration over one plugin's view would stop every plugin and could crash-loop -the agent at startup. View GC removes whole views, plugin-owned entries included, and never follows links. - -### Plugin library checks (R76, R88) - -What a plugin can do 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 -(`lib-version`). Shadowing makes inline bundles work with every build, so there is no gate: only an -overlay-introduced set-form `violation contains` for a plugin older than v0.7.1 is rejected (that `policy-manager` -panics on it). File bundles and unknown versions (a `replace` or devel build, which local -development relies on) only warn. Versions compare as semver and a pseudo-version counts as its base tag, so -release candidates and untagged commits before the minimum are older. - -### Stable inline paths (R67) - -`policy-manager` seeds evidence UUIDs with the policy file path. Materialized bundles stay write-once, -content-addressed directories (`/inline///policies`), but plugins receive a bundle that is not -shadowed at `.compliance-framework/policies/_inline//policies`, where `.compliance-framework/policies/_inline/` -is a symlink swapped with an atomic rename. The path is relative, like an OCI source's, so it does not depend on the -state directory, and `_inline` cannot collide with the OCI cache next to it (repository path components start with -`[a-z0-9]`). The symlink is an intermediate path component on purpose: OPA's bundle loader does not descend into a -symlinked root directory and would silently load nothing. The run loop swaps it only -between two configuration runs, after the reload drain, and on a fallback; a plugin run therefore sees one tree from -start to end, and its API helper resolves the path when the run starts, so evidence artifacts are the tree the run -evaluated. The candidate identity still hashes the digest directories, so any tree change restarts the plugins. GC and -the swap share a lock; GC keeps the trees of the running, pending, starting and fallback candidates and whatever -the bundle's link points to. +### 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. ### One channel for policy sources (R62) -The UI needs the vendor sources to pre-fill an override, and the API never sees them. Rather than a second route, the +The UI needs to show the policy sources an instance loads, and the API never sees them. Rather than a second route, the agent reuses the evidence artifact store: one process-wide `runner.ArtifactUploader` is shared by the reconciler and every plugin's API helper, and one archiver (`internal/policytree`: `ReadTree` + `TarFiles`) serves both, so the configuration-time and evaluation-time uploads of a tree are the same bytes and the same artifact. At report time the reconciler uploads each reported tree once (memoized by tree digest per API) within the remote request timeout and -fills `artifact-digest` on the bundle and its `extends`; the field survives report truncation. Failures leave it empty +fills `artifact-digest` on its `policy-bundles[]` entry; the field survives report truncation. Failures leave it empty and never reject or fail a revision. ### Plugin environment filter @@ -155,8 +85,8 @@ verbatim as `If-None-Match`; it never builds one from a revision number, so a re 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, unsafe changes and (up to 100) policy errors, so the agent re-reports it after a restart -and while the fetch keeps answering 304. +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) @@ -166,7 +96,7 @@ origin: an error at a pointer the overlay touched (equal, prefix or extension, s 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` (except in `policy_bundles`, a new feature). They are reported as warnings; nothing is +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. diff --git a/docs/configuration.md b/docs/configuration.md index 9314d96..d36b4e5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -178,7 +178,7 @@ must already be strings: a remote `port: 2222` (a number) is rejected with `inva 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 -and policy bundle names must match `^[a-z0-9][a-z0-9_-]{0,62}$` (R28). +names an overlay introduces must match `^[a-z0-9][a-z0-9_-]{0,62}$` (R28). ## `${env:NAME}` placeholders @@ -214,162 +214,6 @@ warnings, and are kept unchanged: a negative `verbosity` (`-1` logs WARN and abo 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. -## Policy bundles - -`policy_bundles` define policy paths inline, in the file or in a remote overlay. A plugin uses a bundle by listing -`inline:` in its `policies`: - -```yaml -plugins: - ssh: - source: ghcr.io/compliance-framework/plugin-local-ssh:v1 - policies: - - inline:ssh-hardening - policy_data: - max_auth_tries: 3 - -policy_bundles: - ssh-hardening: - extends: ghcr.io/compliance-framework/plugin-local-ssh-policies:v1 # optional: OCI tag or local path - delete: - - legacy_ciphers.rego # remove a vendor module (needs extends) - modules: # add or override modules - max_auth_tries.rego: | - package compliance_framework.max_auth_tries - - import rego.v1 - - title := "SSH allows at most data.max_auth_tries authentication attempts" - - violation contains {"id": "too-many", "remarks": "too many"} if input.max_auth_tries > data.max_auth_tries - data: # merge-patched into data.json - allowed_ciphers: ["aes256-gcm@openssh.com"] -``` - -- **Order (R17).** The `extends` tree is copied, then `delete` removes vendor files, then `modules` add or override - files, then `data` is merged (RFC 7396) onto the root `data.json` (a root `data.yaml` is converted) and written as - `data.json`. -- **Paths (R18)** are relative to the policy root the plugin receives, e.g. `max_auth_tries.rego`, not - `policies/max_auth_tries.rego`. `..`, absolute paths and empty segments are rejected. Symlinks inside a local - `extends` tree are skipped. -- **Data files (R18).** OPA only loads `data.json`, `data.yaml` and `data.yml`. Any other `.json`/`.yaml`/`.yml` - module is an error (a warning when it comes from the vendor tree). Setting both `data` and a root `data.json` - module is an error. -- **Remote overlays.** An overlay `modules."": null` removes the effective module: an inherited vendor module shows - through again, and a module only the file defined is dropped. Omitting the key keeps the file's value. -- **One compile unit per policy path (R21).** Plugins load each policy path as a separate bundle, so a bundle cannot - import packages from another policy path; cross-bundle imports are unsupported. Put shared helpers in the bundle (or - its `extends` tree). -- **Checks.** Before a bundle is used the agent compiles it exactly as the plugin will, rejects any use of - `http.send`, `net.lookup_ip_addr` or `opa.runtime` reachable from the bundle's own rules (including through vendor - helpers and `with ... as http.send`), and runs its Rego tests with the plugin's `policy_data`. A failing test that the - bundle authored rejects the configuration; a failing vendor test is only a warning. Tests never run when a forbidden - builtin is reachable, and they run sandboxed: a test that calls one of those builtins fails instead of executing it. -- **Policy contract (R63).** A `compliance_framework.*` package must produce what the agent needs to record evidence: - a string `title`, `violation` as a set of objects with string `id`/`title`/`description`/`remarks`, `labels` as a - map of strings, and valid `risk_templates`. The API checks the authored modules statically when an overlay is saved. - The agent, which sees the whole tree, also checks the vendor packages statically and then dry-runs every package - on an empty input (`{}` plus the plugin's `policy_data`), sandboxed, through the same calls a plugin makes. A - problem in a package that has an authored non-test module (including an override) is an **error**; in a package - only the vendor defines (an authored test alone does not count) it is a **warning**, as is an evaluation conflict that only shows on `{}` or a `title` that depends on - the input. So an override that leaves a package without a `title` is rejected: that package would record no - evidence. -- **Vendor tests that no longer compile (R65)** reject the revision: plugins compile `_test.rego` files too, so such a - bundle would produce no evidence at all. When the failing file is a vendor file in a package an authored module - also defines, the error carries a hint: keep the rule the override removed, or add the test to `delete`. -- **Duplicate evidence (R66, R75).** A plugin evaluates each of its policy paths separately, so a policy it loads - twice is recorded twice. Across all of a plugin's policy paths the agent reports: - - `duplicate-policy-identity`: two paths load the same evidence identity, the same package and bundle-relative - file (typically a source listed next to an inline bundle that `extends` it); - - `duplicate-policy-package` (warning): the same package from two paths otherwise. - - The first is an **error** when the overlay introduces it (it gives the plugin one of the policy entries involved, - or changes one of the bundles involved) and a warning when it comes from the file (R34), even under an overlay that - changes something else. Replace the source with the inline bundle - instead of listing both. -- **Path shadowing (R83): inline bundles keep the vendor's evidence streams.** Plugins seed evidence UUIDs from the - policy path *string* they receive, so a plugin that keeps receiving the vendor's path keeps the vendor's streams, - whatever agent library it was built with. When a bundle `extends` a source whose plugin path is relative and ends - in `policies/` (every OCI source, e.g. - `.compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies`), plugins receive the - bundle **at that exact path**, and each plugin that uses such a bundle runs in its own **view**, - `/views///`, as its working directory (the plugin binary is started by absolute path). In - the view, the path's parent is a symlink to the bundle's content-addressed directory (whose `policies/` is real: - OPA loads nothing from a symlinked root), and every other entry of the agent's working directory is mirrored as a - symlink, so every other relative path resolves as it does for the agent. Inherited modules, and overrides that - keep the vendor module's `package`, continue the vendor streams through the path alone; new modules start their - own path-based streams, deleted ones stop. `_policy_source` says `inline:` (also for plugins that send no - policy evaluations, from their `_policy_path` label) and evaluation-time artifacts are read through the view, i.e. - from the bundle's tree. Views are content-addressed (a new revision is a new view, nothing is swapped under a - running plugin) and garbage-collected with the inline trees. -- **Bundles that are not shadowed (R88)** are given to plugins at their own path under `_inline` (below), so their - modules start path-based streams. That happens when the `extends` path is absolute (or not a `policies/` tree), - when a plugin using the bundle also loads the source itself (reported as `duplicate-policy-identity` as before) or - another bundle extending the same source, when another relative policy path of the plugin cannot be represented in - a view (e.g. a single-component path such as `policies`), or without symlinks or a writable state directory. Each - plugin that uses such a bundle gets a `policy-stream-forked` warning for the bundle that gives the reason and lists - the modules that would have kept the vendor's streams at the source's path. -- **Overrides and evidence streams (R75).** An override keeps the evidence stream of the vendor module it replaces - unless it changes the module's `package`, which is a `policy-package-changed` warning. -- **Plugin views (rule 1).** A plugin running in a view sees its working directory as the view: files it creates - there (rather than through a mirrored directory) stay in the view and are removed with it. - **Plugin contract:** plugins must not rely on creating new files or directories relative to their working - directory; use absolute paths or `os.TempDir()`. Such entries are the plugin's: if the agent's working directory - later gets an entry with the same name, the plugin keeps its own (and does not see the agent's); the agent logs one - warning per view and name, and never fails the plugin's run or the configuration's activation over it. The one - exception is the shadowed path's link itself (the parent of the vendor path), which is the agent's: a real entry - there means the plugin replaced the link, so **that plugin's runs fail** with an error naming it, until the entry - is removed (deleting the view directory rebuilds it). Activation only logs it, so the configuration and the other - plugins keep running, and a restart does not fail on it either. The agent never removes the entry. -- **Local `extends`** may be a symlinked directory (it is resolved before reading); an `extends` tree without any - `.rego` file fails with `download-failed`. -- **Where bundles live (R67).** Inline bundles are never downloaded. Each revision of a bundle is a write-once - directory under the state directory named by its tree digest, `/inline///policies/`. A - bundle that is not shadowed always reaches plugins at the same relative path, - `.compliance-framework/policies/_inline//policies` (relative to the agent's working directory, like an OCI - source's path; the leading `_` keeps it apart from the OCI cache, whose repository paths start with `[a-z0-9]`): - `.compliance-framework/policies/_inline/` is a symlink the agent swaps atomically to the running revision's - directory, between two configuration runs (never while a plugin of the previous configuration runs). Evidence UUIDs - are seeded with the policy file path, so an unchanged module keeps its evidence identity across edits of the - bundle, and does not depend on the state directory. Two agents that share a working directory and use the same - bundle name would swap the same link: run one agent per working directory. On a file system without symlinks - (Windows without the privilege), plugins receive the digest directory itself and evidence identity changes with - each revision. Directories no running, pending or fallback configuration uses are garbage-collected, never the one - the link points to. -- **Sources for the UI (R62).** Outside mode `off`, the agent uploads every policy tree it reports (each inline - bundle, the tree it extends, and each OCI or local source a plugin uses) as a policy bundle artifact, and reports - its `artifact-digest` next to the tree digest, so the UI can show and pre-fill vendor sources. These are the same - artifacts evidence references for playback: one uploader serves both, and a tree is uploaded once. Uploads are - best effort: a failure (an API without artifacts, a tree over the API's size limit, a timeout) leaves - `artifact-digest` empty and never rejects or fails a revision. Note that artifacts are readable with - `artifact:read`. - -### Evidence streams - -Plugins seed each evidence UUID with the policy's package, its file (the plugin path joined with the module's path in -the bundle) and their labels, including `_policy_path` (the plugin path). So moving a policy (a new OCI tag, a renamed -bundle, a bundle that is not shadowed) starts a new evidence stream; a shadowed bundle keeps the vendor's. - -| Change | Evidence stream | -|---|---| -| override a policy in a shadowed bundle, keeping its `package` | the same stream | -| `delete` the policy | the stream stops receiving evidence | -| revert the override | the same stream | -| a new policy | a new stream | -| change the overridden module's `package` | a new stream (`policy-package-changed`) | - -### Plugin compatibility (R76, R88) - -The agent reads each plugin's agent library version from the binary's Go build info, without starting it, and -reports it as `plugins[]` (`name`, `source`, `lib-version`). Inline bundles work with every plugin build; one -construct depends on the library: - -- **Set-form violations** (`violation contains {...}`) crash plugins built on agent < v0.7.1, which expect - `violation[{...}] if { ... }`: an overlay-introduced authored module that uses them for such a plugin is rejected - with `plugin-lib-violation-set-unsupported`, with that fix. The running configuration keeps running. -- **Unknown versions and file-defined bundles only warn** (R34): a `replace`d or `(devel)` build, a pseudo-version - with no tag before it, or a binary without build info. - ## Remote configuration An agent with `api.auth` credentials can pick up a configuration overlay stored in the API. The `remote_config` block @@ -381,13 +225,12 @@ remote_config: 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_inline_policies: true allow_local_sources: false ``` Defaults (R29): `mode` is `apply_safe` when `api.auth` is set and `off` otherwise (no credentials always forces -`off`); `poll_interval` is `60s`; `trusted_sources` and `overridable_config_flags` are empty; `allow_inline_policies` -is `true`; `allow_local_sources` is `false`. `CCF_REMOTE_CONFIG_MODE` sets the mode even when the file has no +`off`); `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 | @@ -404,34 +247,51 @@ A change is classified as follows (the agent is the authority; the API preview u | `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, a policy entry or a bundle | safe | -| a plugin source or policy entry already used by the file (or by a bundle's `extends`) | 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) | -| an inline bundle change or `inline:` policy entry | safe while `allow_inline_policies` is true, else 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, policy checks) 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`, -`policy-errors`, `env-missing`, `download-failed`, `cache-corrupt`, `internal`). When a new configuration is applied, +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 and policy -errors) is reported again, so the instance still shows as rejected after a restart. +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. + +### Sources for the UI (R62) + +Outside mode `off`, the configuration report inventories every policy source the instance's plugins load (each OCI or +local source) as `policy-bundles[]`: the source, its tree digest and its files (path, SHA-256 and, for a Rego module, +its package). The agent also uploads each tree as a policy bundle artifact and reports its `artifact-digest` next to the +tree digest, so the UI can show the sources. These are the same artifacts evidence references for playback: one +uploader serves both, and a tree is uploaded once. Uploads are best effort: a failure (an API without artifacts, a tree +over the API's size limit, a timeout) leaves `artifact-digest` empty and never rejects or fails a revision. Note that +artifacts are readable with `artifact:read`. When the report is too large, the file lists are dropped first, then the +`base` document; the digests are kept. + +### Plugin library versions (R76) + +The report also 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`), the remote -configuration cache and materialized inline bundles. The OCI download caches in `.compliance-framework/plugins` and +`` 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 | diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 7051b78..3e0080c 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -27,11 +27,9 @@ The API does all canonicalisation and hashing; see its `docs/artifacts.md`. digests, before the next message arrives. The raw data is never forwarded with the evidence, and the agent never holds the whole batch in memory. -The agent only reads bundles at the policy paths it gave that plugin, resolved when the -run starts (an inline bundle's stable path is a symlink the agent may point elsewhere -later), so the bundle artifact is the tree the run evaluated. Symlinks inside a bundle are -skipped, as OPA skips them. The agent remembers what it has already uploaded to each API, -so unchanged content is not uploaded again on later runs. The same process-wide uploader +The agent only reads bundles at the policy paths it gave that plugin. Symlinks inside a +bundle are skipped, as OPA skips them. The agent remembers what it has already uploaded to +each API, so unchanged content is not uploaded again on later runs. The same process-wide uploader serves the configuration report, which uploads the policy trees it names (see `configuration.md`, "Sources for the UI"); a tree uploaded there is not uploaded again for evidence, and both produce the same artifact digest. @@ -80,8 +78,8 @@ 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). For an inline bundle, its entry `inline:` | -| `_policy_digest` | For an OCI source, the registry digest the reference resolved to when the agent downloaded it. For an inline bundle, the artifact digest of the bundle the evaluation stored, or the bundle's tree digest (`tree:sha256:…`, as in the configuration report) when it could not be stored. Not set for a local directory; `_policy_bundle_digest` covers its content | +| `_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 later moves. @@ -92,12 +90,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 exact path it gave the plugin, which is the -path the plugin reports the evaluation under. For an inline bundle that is the path plugins -receive for it: the extends source's path when the bundle is shadowed (resolved through the -plugin's view), else the bundle's stable path (`.compliance-framework/policies/_inline//policies`), -so its evidence carries these props across revisions. A bundle an inline bundle `extends` is not on the evidence; the -configuration report names it (`policy-bundles[].extends`). +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 eb88331..79055e5 100644 --- a/docs/running_as_a_service.md +++ b/docs/running_as_a_service.md @@ -100,8 +100,8 @@ 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, the remote configuration cache and inline -policy bundles). Without them the agent writes relative to `/`. The state directory must persist across restarts, +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). From eea716226b0b4c94a617027191a47eedaee3af73 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 08:52:41 -0300 Subject: [PATCH 43/47] style: reflow comments Co-Authored-By: Claude Opus 5.5 --- cmd/reconciler.go | 4 ++-- internal/policytree/policytree.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/reconciler.go b/cmd/reconciler.go index add4909..ea71dcc 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -41,8 +41,8 @@ var ( failedRetryMin = time.Minute failedRetryMax = 10 * time.Minute // prepareNetworkTimeout bounds each network step of prepare (plugin/policy prefetch, a - // report inventory), so a hung registry cannot stall the reconciler. A - // timeout is a failed/download-failed, which is retried with the failed backoff. + // report inventory), 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 ) diff --git a/internal/policytree/policytree.go b/internal/policytree/policytree.go index 9df97a9..c0042b8 100644 --- a/internal/policytree/policytree.go +++ b/internal/policytree/policytree.go @@ -24,8 +24,8 @@ import ( // ReadTree reads the regular files under dir, keyed by their slash-separated path relative // to dir. dir itself may be a symlink (for example /etc/ccf/policies -> a versioned -// directory); symlinks inside the tree are skipped and -// returned, as OPA's bundle loader skips them too. Other non-regular files are ignored. +// directory); symlinks inside the tree are skipped and returned, as OPA's bundle loader +// skips them too. Other non-regular files are ignored. func ReadTree(dir string) (files map[string][]byte, skipped []string, err error) { files = map[string][]byte{} root, err := filepath.EvalSymlinks(dir) From 69083bc033da2567b856e31a35eb8c130196e82d Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 11:43:18 -0300 Subject: [PATCH 44/47] refactor!: drop the report-time policy bundle inventory and uploads The config report no longer inventories the policy sources (policy-bundles[] with tree digests and file lists), and the reconciler no longer uploads policy trees as artifacts at report time. The API removed the matching report fields and the artifact file routes. The evaluation-time policy bundle upload (evidence playback) is restored to main: each plugin run's API helper has its own uploader again, and the shared process-wide ArtifactUploader/ArtifactEndpoint and internal/policytree, which only existed to share code with the report-time upload, are gone. Co-Authored-By: Claude Opus 5.5 --- cmd/agent.go | 19 +- cmd/artifacts.go | 200 ------------------- cmd/artifacts_test.go | 255 ------------------------- cmd/reconciler.go | 58 +----- cmd/remote_test.go | 48 +---- cmd/report.go | 13 +- internal/policytree/policytree.go | 135 ------------- internal/policytree/policytree_test.go | 147 -------------- runner/policy_artifacts.go | 231 +++++++++++----------- runner/policy_artifacts_test.go | 38 +--- runner/result.go | 34 +--- 11 files changed, 138 insertions(+), 1040 deletions(-) delete mode 100644 cmd/artifacts.go delete mode 100644 cmd/artifacts_test.go delete mode 100644 internal/policytree/policytree.go delete mode 100644 internal/policytree/policytree_test.go diff --git a/cmd/agent.go b/cmd/agent.go index 6702305..664cf73 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -360,16 +360,9 @@ func agentRunner(cmd *cobra.Command, args []string) error { // 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) - // One artifact uploader for the process: the reconciler's policy tree uploads and every - // plugin run's evidence artifacts share what each API already has (R62). - artifacts := runner.NewArtifactUploader() - ar := NewAgentRunner(WithInstanceID(id), WithSharedArtifactUploader(artifacts)) + ar := NewAgentRunner(WithInstanceID(id)) rc := newReconciler(cmd, configPath, store, ar, logger) rc.instanceID = id - rc.artifacts = artifacts - rc.resolvePolicy = func(ctx context.Context, source string) (string, error) { - return ar.downloadPolicy(ctx, source, logger) - } pluginLibs := &pluginlib.Cache{} rc.pluginLib = func(ctx context.Context, source string) (string, error) { binary, err := ar.downloadPlugin(ctx, source, logger) @@ -459,8 +452,6 @@ type AgentRunner struct { // instanceID is this agent instance's stable ID (R31); set once at construction. instanceID uuid.UUID - // artifacts is the process-wide artifact uploader, shared with the reconciler (R62). - artifacts *runner.ArtifactUploader // 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 @@ -477,11 +468,6 @@ func WithInstanceID(id uuid.UUID) AgentRunnerOption { return func(ar *AgentRunner) { ar.instanceID = id } } -// WithSharedArtifactUploader makes every plugin run upload through u (R62). -func WithSharedArtifactUploader(u *runner.ArtifactUploader) AgentRunnerOption { - return func(ar *AgentRunner) { ar.artifacts = u } -} - func NewAgentRunner(opts ...AgentRunnerOption) *AgentRunner { ar := &AgentRunner{ pluginLocations: map[string]string{}, @@ -492,7 +478,6 @@ func NewAgentRunner(opts ...AgentRunnerOption) *AgentRunner { httpClient: http.DefaultClient, instanceID: uuid.New(), protocolCache: map[string]int32{}, - artifacts: runner.NewArtifactUploader(), } for _, opt := range opts { opt(ar) @@ -1471,7 +1456,6 @@ 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.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...), ) @@ -1617,7 +1601,6 @@ func (ar *AgentRunner) runPluginWith(ctx context.Context, snap runSnapshot, name resultsHelper := runner.NewApiHelper(pluginLogger, client, labels, name, runner.WithPolicyPaths(policyPaths), runner.WithSources(sourceOf(plugin.Source, pluginExecutable), policySources), - runner.WithArtifactUploader(ar.artifacts, apiBaseURL(config)), runner.WithEvidenceProps(configRevisionProps(config)...), ) diff --git a/cmd/artifacts.go b/cmd/artifacts.go deleted file mode 100644 index 168841a..0000000 --- a/cmd/artifacts.go +++ /dev/null @@ -1,200 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "maps" - "net/http" - "regexp" - "slices" - - "github.com/compliance-framework/agent/internal/policytree" - "github.com/compliance-framework/agent/runner" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/sdk" -) - -// Policy sources through the artifact store (R62). The report names every policy tree the -// agent runs (OCI and local sources) by tree digest. The agent also uploads each tree as a policy bundle artifact, through the same -// process-wide uploader the plugins' evidence uses, and reports the artifact digest next to -// the tree digest, so the UI can read the sources (GET /api/artifacts/{digest}/files). -// -// Uploads are best effort: a failure leaves artifact-digest empty and never rejects or fails -// a revision. They happen at report time, so only in report and apply modes, only for the -// candidate that runs (its checks passed), within remoteRequestTimeout per report. - -// artifactMemoLimit bounds the tree digest -> artifact digest memo. -const artifactMemoLimit = 1024 - -// artifactDigestPattern is the format of an artifact digest (the API checks the same). -var artifactDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) - -// errTreeChanged: a local tree no longer has the digest it was inventoried with. -var errTreeChanged = errors.New("the policy tree changed since it was inventoried") - -// policyResolver returns the local directory of an OCI or local policy source, downloading it -// into the shared policy cache when needed. -type policyResolver func(ctx context.Context, source string) (dir string, err error) - -// sourceReports inventories the policy sources of runtime for the report, with the trees to -// upload as artifacts. A source that cannot be resolved or read is left out. -func (rc *reconciler) sourceReports(ctx context.Context, runtime *agentConfig) ([]agentconfig.PolicyBundleReport, []artifactTree) { - sources := map[string]struct{}{} - for _, p := range runtime.Plugins { - for _, e := range p.Policies { - sources[string(e)] = struct{}{} - } - } - var reports []agentconfig.PolicyBundleReport - var trees []artifactTree - for _, source := range slices.Sorted(maps.Keys(sources)) { - dir, r, err := rc.sourceInventory(ctx, source) - if err != nil { - continue - } - reports = append(reports, r) - trees = append(trees, artifactTree{digest: r.Digest, dir: dir}) - } - return reports, trees -} - -// sourceInventory resolves an OCI or local policy source and inventories its tree. OCI trees -// are memoized per (source, dir); local trees are re-read. -func (rc *reconciler) sourceInventory(ctx context.Context, source string) (string, agentconfig.PolicyBundleReport, error) { - if rc.resolvePolicy == nil { - return "", agentconfig.PolicyBundleReport{}, errors.New("no policy resolver") - } - resolveCtx, cancel := context.WithTimeout(ctx, prepareNetworkTimeout) - dir, err := rc.resolvePolicy(resolveCtx, source) - cancel() - if err != nil { - return "", agentconfig.PolicyBundleReport{}, err - } - key := source + "\x00" + dir - if r, ok := rc.inventoryMemo[key]; ok { - return dir, r, nil - } - digest, files, err := policytree.Inventory(dir) - if err != nil { - return "", agentconfig.PolicyBundleReport{}, err - } - r := agentconfig.PolicyBundleReport{Source: source, Digest: digest, Files: files} - if agentconfig.KindOf(source) == agentconfig.SourceKindOCI { - rc.inventoryMemo[key] = r - } - return dir, r, nil -} - -// artifactTree is a policy tree the report names, and the directory it was read from. -type artifactTree struct { - digest string // agentconfig.BundleTreeDigest of the tree - dir string -} - -// uploadArtifacts uploads the trees of c that have no artifact yet. Each tree is uploaded -// once per process (memoized by tree digest); a tree the API refused for good is not retried. -func (rc *reconciler) uploadArtifacts(ctx context.Context, c *candidate) { - if rc.remote == nil || c == nil || len(c.trees) == 0 { - return - } - var pending []artifactTree - for _, t := range c.trees { - if _, done := rc.artifactMemo[t.digest]; !done && t.dir != "" { - pending = append(pending, t) - } - } - if len(pending) == 0 { - return - } - ctx, cancel := context.WithTimeout(ctx, remoteRequestTimeout) - defer cancel() - for _, t := range pending { - if _, done := rc.artifactMemo[t.digest]; done { - continue // the same tree twice in c - } - digest, err := rc.uploadTree(ctx, t) - var statusErr *sdk.ArtifactStatusError - switch { - case err == nil: - rc.rememberArtifact(t.digest, digest) - case errors.Is(err, runner.ErrArtifactsUnsupported): - if rc.logOnce("artifacts:unsupported") { - rc.logger.Info("The API does not support policy artifacts; the report names policy trees without their sources") - } - return - case ctx.Err() != nil: - if rc.logOnce("artifacts:timeout") { - rc.logger.Warn("Uploading policy trees as artifacts timed out; retrying with the next report", "timeout", remoteRequestTimeout) - } - return - case errors.Is(err, errTreeChanged): - // The candidate's report is stale for this tree; do not re-read it every poll. - rc.rememberArtifact(t.digest, "") - if rc.logOnce("artifacts:" + t.digest) { - rc.logger.Warn("A policy tree changed on disk since it was inventoried; the report names it without its sources", "dir", t.dir, "error", err) - } - case errors.As(err, &statusErr) && permanentArtifactFailure(statusErr.StatusCode): - rc.rememberArtifact(t.digest, "") - if rc.logOnce("artifacts:" + t.digest) { - rc.logger.Warn("The API refused a policy tree artifact; the report names it without its sources", "tree", t.digest, "status", statusErr.StatusCode, "error", err) - } - default: - if rc.logOnce("artifacts:" + t.digest) { - rc.logger.Warn("Could not upload a policy tree artifact; retrying with the next report", "tree", t.digest, "error", err) - } - } - } -} - -// uploadTree archives t exactly as the plugins' API helper archives a policy path, so both -// get the same artifact. A tree that changed on disk since it was inventoried (a local -// source edited in place) is not uploaded under the old digest. -func (rc *reconciler) uploadTree(ctx context.Context, t artifactTree) (string, error) { - files, _, err := policytree.ReadTree(t.dir) - if err != nil { - return "", err - } - if got := agentconfig.BundleTreeDigest(files); got != t.digest { - return "", fmt.Errorf("%w: %s was %s, now %s", errTreeChanged, t.dir, t.digest, got) - } - tarball, err := policytree.TarFiles(files) - if err != nil { - return "", err - } - digest, err := rc.remote.UploadArtifact(ctx, sdk.ArtifactMediaTypePolicyBundle, tarball) - if err != nil { - return "", err - } - if !artifactDigestPattern.MatchString(digest) { - return "", fmt.Errorf("the API returned an invalid artifact digest %q", digest) - } - return digest, nil -} - -// permanentArtifactFailure reports whether an upload status means the API will never take -// this content (too large, invalid). 404/405 (no artifact support), 408 and 429 are not. -func permanentArtifactFailure(status int) bool { - switch status { - case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusRequestTimeout, http.StatusTooManyRequests: - return false - } - return status >= 400 && status < 500 -} - -func (rc *reconciler) rememberArtifact(tree, artifact string) { - if len(rc.artifactMemo) >= artifactMemoLimit { - rc.artifactMemo = map[string]string{} - } - rc.artifactMemo[tree] = artifact -} - -// withArtifactDigests returns bundles with the artifact digests known for their trees. It -// copies what it changes: the candidate's reports are shared. -func (rc *reconciler) withArtifactDigests(bundles []agentconfig.PolicyBundleReport) []agentconfig.PolicyBundleReport { - out := append([]agentconfig.PolicyBundleReport(nil), bundles...) - for i := range out { - out[i].ArtifactDigest = rc.artifactMemo[out[i].Digest] - } - return out -} diff --git a/cmd/artifacts_test.go b/cmd/artifacts_test.go deleted file mode 100644 index edbc694..0000000 --- a/cmd/artifacts_test.go +++ /dev/null @@ -1,255 +0,0 @@ -package cmd - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "net/http" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "github.com/compliance-framework/agent/internal/policytree" - "github.com/compliance-framework/agent/runner" - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/compliance-framework/api/sdk" -) - -func tarDigest(t *testing.T, dir string) string { - t.Helper() - tarball, err := policytree.TarDirectory(dir) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(tarball) - return "sha256:" + hex.EncodeToString(sum[:]) -} - -// vendorBaseConfig runs plugin ssh with the OCI policy source ghcr.io/vendor/policies:v1. -const vendorBaseConfig = ` -daemon: true -api: - url: http://api.test - auth: - client_id: 123e4567-e89b-12d3-a456-426614174000 - client_secret: s3cret -remote_config: - mode: apply_safe -plugins: - ssh: - source: ghcr.io/compliance-framework/plugin-ssh:v1 - policies: ["ghcr.io/vendor/policies:v1"] -` - -// newVendorHarness is a remote harness on config whose policy resolver serves -// ghcr.io/vendor/policies:v1 from a temporary tree, which it returns. -func newVendorHarness(t *testing.T, config string) (*remoteHarness, string) { - t.Helper() - vendor := t.TempDir() - if err := os.WriteFile(filepath.Join(vendor, "banner.rego"), []byte("package compliance_framework.banner\n\nimport rego.v1\n\nviolation contains {\"remarks\": \"b\"} if not input.banner\n"), 0o644); err != nil { - t.Fatal(err) - } - h := newRemoteHarness(t, config) - h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { - if source == "ghcr.io/vendor/policies:v1" { - return vendor, nil - } - return "", errors.New("unknown source " + source) - } - return h, vendor -} - -// TestArtifacts_ReportNamesTheSources_R62: the report inventories the policy source and -// carries the artifact digest of its tree, the digest a plugin's evaluation-time upload of -// the same directory produces. -func TestArtifacts_ReportNamesTheSources_R62(t *testing.T) { - h, vendor := newVendorHarness(t, vendorBaseConfig) - active := mustStartup(t, h.rc) - - r := h.remote.lastReport(t) - if len(r.PolicyBundles) != 1 { - t.Fatalf("unexpected policy-bundles %+v", r.PolicyBundles) - } - b := r.PolicyBundles[0] - if b.Source != "ghcr.io/vendor/policies:v1" || len(b.Files) != 1 || b.Files[0].Path != "banner.rego" || b.Files[0].Package != "compliance_framework.banner" { - t.Fatalf("unexpected inventory %+v", b) - } - if want := tarDigest(t, vendor); b.ArtifactDigest != want { - t.Fatalf("artifact-digest = %q, want %q", b.ArtifactDigest, want) - } - if n := h.remote.uploadCount(); n != 1 { - t.Fatalf("expected one upload per tree, got %d", n) - } - - // Memoized: later reports upload nothing. - h.clock.Advance(reportResendInterval + 1) - h.rc.maybeReport(context.Background(), active, nil) - if n := h.remote.uploadCount(); n != 1 { - t.Fatalf("trees must be uploaded once, got %d uploads", n) - } - - // The digests survive dropping the file lists to fit the report size. - _, _, err := fitReport(&r, true) - if err != nil { - t.Fatal(err) - } - if len(r.PolicyBundles[0].Files) != 0 || r.PolicyBundles[0].ArtifactDigest != b.ArtifactDigest { - t.Fatalf("truncation must keep artifact-digest: %+v", r.PolicyBundles[0]) - } -} - -func TestArtifacts_SourcesAreUploaded(t *testing.T) { - local := t.TempDir() - if err := os.WriteFile(filepath.Join(local, "p.rego"), []byte("package compliance_framework.p\n\ntitle := \"p\"\n"), 0o644); err != nil { - t.Fatal(err) - } - h := newRemoteHarness(t, strings.Replace(vendorBaseConfig, `policies: ["ghcr.io/vendor/policies:v1"]`, `policies: ["ghcr.io/vendor/policies:v1", "`+local+`"]`, 1)) - h.rc.resolvePolicy = func(_ context.Context, source string) (string, error) { - switch source { - case "ghcr.io/vendor/policies:v1": - return local, nil - case local: - return local, nil - } - return "", errors.New("unknown source " + source) - } - mustStartup(t, h.rc) - r := h.remote.lastReport(t) - var found bool - for _, b := range r.PolicyBundles { - if b.Source == local { - found = true - if want := tarDigest(t, local); b.ArtifactDigest != want { - t.Fatalf("local source artifact-digest = %q, want %q", b.ArtifactDigest, want) - } - } - } - if !found { - t.Fatalf("the local source is not reported: %+v", r.PolicyBundles) - } -} - -// TestArtifacts_FailuresNeverRejectOrFail: an upload failure leaves artifact-digest empty; -// the revision applies, and a refused tree is not retried. -func TestArtifacts_FailuresNeverRejectOrFail(t *testing.T) { - for name, tc := range map[string]struct { - err error - wantRetried bool - }{ - "old API": {err: runner.ErrArtifactsUnsupported}, - "too large": {err: &sdk.ArtifactStatusError{StatusCode: http.StatusRequestEntityTooLarge}}, - "invalid": {err: &sdk.ArtifactStatusError{StatusCode: http.StatusBadRequest}}, - "rate limited": {err: &sdk.ArtifactStatusError{StatusCode: http.StatusTooManyRequests}, wantRetried: true}, - "transport error": {err: errors.New("connection reset"), wantRetried: true}, - "timeout": {err: context.DeadlineExceeded, wantRetried: true}, - } { - t.Run(name, func(t *testing.T) { - h, _ := newVendorHarness(t, vendorBaseConfig) - failing := true - h.remote.uploadErr = func(int) error { - if failing { - return tc.err - } - return nil - } - active := mustStartup(t, h.rc) - r := h.remote.lastReport(t) - if r.Status == agentconfig.StatusRejected || r.Status == agentconfig.StatusFailed { - t.Fatalf("an artifact failure must not reject or fail: %s/%s", r.Status, r.Reason) - } - if r.PolicyBundles[0].ArtifactDigest != "" { - t.Fatalf("a failed upload must leave artifact-digest empty: %+v", r.PolicyBundles[0]) - } - - failing = false - before := h.remote.uploadCount() - h.clock.Advance(reportResendInterval + 1) - h.rc.maybeReport(context.Background(), active, nil) - retried := h.remote.uploadCount() > before - if errors.Is(tc.err, runner.ErrArtifactsUnsupported) { - // The shared uploader owns the old-API backoff; the reconciler retries on - // the next report and the uploader answers without a request. - return - } - if retried != tc.wantRetried { - t.Fatalf("retried = %v, want %v", retried, tc.wantRetried) - } - if tc.wantRetried && h.remote.lastReport(t).PolicyBundles[0].ArtifactDigest == "" { - t.Fatal("a retried upload must fill artifact-digest") - } - }) - } -} - -// TestArtifacts_ModeOffUploadsNothing: no report, no upload. -func TestArtifacts_ModeOffUploadsNothing(t *testing.T) { - h, _ := newVendorHarness(t, strings.Replace(vendorBaseConfig, "mode: apply_safe", `mode: "off"`, 1)) - mustStartup(t, h.rc) - if n := h.remote.uploadCount(); n != 0 { - t.Fatalf("mode off must not upload, got %d", n) - } -} - -// TestReport_PluginsCarryTheirLibVersion_R76: the report lists every plugin with the agent -// library its binary was built with; a version that cannot be read is reported as unknown. -func TestReport_PluginsCarryTheirLibVersion_R76(t *testing.T) { - config := vendorBaseConfig + ` aws: - source: ghcr.io/compliance-framework/plugin-aws:v1 -` - h, _ := newVendorHarness(t, config) - h.rc.pluginLib = func(_ context.Context, source string) (string, error) { - if source == "ghcr.io/compliance-framework/plugin-ssh:v1" { - return "v0.7.1", nil - } - return "", errors.New("not a Go binary") - } - mustStartup(t, h.rc) - got := h.remote.lastReport(t).Plugins - want := []agentconfig.PluginReport{ - {Name: "aws", Source: "ghcr.io/compliance-framework/plugin-aws:v1"}, - {Name: "ssh", Source: "ghcr.io/compliance-framework/plugin-ssh:v1", LibVersion: "v0.7.1"}, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("plugins = %+v, want %+v", got, want) - } -} - -// TestArtifacts_SharedUploaderDedupesWithEvaluation: the reconciler and a plugin's API -// helper share the process-wide uploader, so a tree the reconciler uploaded is not uploaded -// again when the plugin's evidence references it. -func TestArtifacts_SharedUploaderDedupesWithEvaluation(t *testing.T) { - var uploads int - client := &countingArtifacts{n: &uploads} - shared := runner.NewArtifactUploader() - remote := sdkRemote{artifacts: shared.Endpoint("http://api.test", client)} - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "p.rego"), []byte("package compliance_framework.p\n"), 0o644); err != nil { - t.Fatal(err) - } - tarball, err := policytree.TarDirectory(dir) - if err != nil { - t.Fatal(err) - } - for range 2 { - if _, err := remote.UploadArtifact(context.Background(), sdk.ArtifactMediaTypePolicyBundle, tarball); err != nil { - t.Fatal(err) - } - if _, err := shared.Endpoint("http://api.test", client).Upload(context.Background(), sdk.ArtifactMediaTypePolicyBundle, tarball); err != nil { - t.Fatal(err) - } - } - if uploads != 1 { - t.Fatalf("expected one upload, got %d", uploads) - } -} - -type countingArtifacts struct{ n *int } - -func (c *countingArtifacts) Upload(_ context.Context, mediaType string, content []byte) (*sdk.ArtifactInfo, error) { - *c.n++ - sum := sha256.Sum256(content) - return &sdk.ArtifactInfo{Digest: "sha256:" + hex.EncodeToString(sum[:]), MediaType: mediaType}, nil -} diff --git a/cmd/reconciler.go b/cmd/reconciler.go index ea71dcc..affddff 100644 --- a/cmd/reconciler.go +++ b/cmd/reconciler.go @@ -17,7 +17,6 @@ import ( "time" "github.com/compliance-framework/agent/internal/agentstate" - runnerpkg "github.com/compliance-framework/agent/runner" "github.com/compliance-framework/api/pkg/agentconfig" "github.com/compliance-framework/api/sdk" "github.com/fsnotify/fsnotify" @@ -40,9 +39,9 @@ var ( // failedRetryMin / failedRetryMax bound the retry of a failed/* revision. failedRetryMin = time.Minute failedRetryMax = 10 * time.Minute - // prepareNetworkTimeout bounds each network step of prepare (plugin/policy prefetch, a - // report inventory), so a hung registry cannot stall the reconciler. A timeout is a - // failed/download-failed, which is retried with the failed backoff. + // 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 ) @@ -58,9 +57,6 @@ type candidate struct { // 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 - bundles []agentconfig.PolicyBundleReport - // trees are the policy trees bundles describe, uploaded as artifacts for the report (R62). - trees []artifactTree warnings []agentconfig.FieldError // R34 file-origin warnings // plugins are the runtime's plugins with their agent library versions (R76). plugins []agentconfig.PluginReport @@ -122,27 +118,15 @@ type configReporter interface { Report(ctx context.Context, instanceID uuid.UUID, r agentconfig.Report) error } -// artifactUploader is the test seam over the shared artifact uploader (R62). -type artifactUploader interface { - UploadArtifact(ctx context.Context, mediaType string, content []byte) (string, error) -} - -// remoteAPI bundles the remote configuration calls and the artifact upload. +// remoteAPI bundles the remote configuration calls. type remoteAPI interface { overlayFetcher configReporter - artifactUploader } -// sdkRemote adapts the SDK client to remoteAPI. Artifacts go through the process-wide -// uploader, shared with the plugins' API helpers. +// sdkRemote adapts the SDK client to remoteAPI. type sdkRemote struct { - client *sdk.Client - artifacts *runnerpkg.ArtifactEndpoint -} - -func (s sdkRemote) UploadArtifact(ctx context.Context, mediaType string, content []byte) (string, error) { - return s.artifacts.Upload(ctx, mediaType, content) + client *sdk.Client } func (s sdkRemote) Get(ctx context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) { @@ -154,7 +138,7 @@ func (s sdkRemote) Report(ctx context.Context, instanceID uuid.UUID, r agentconf } // newSDKRemote builds the remote configuration client from the (locked, file-only) api block. -func newSDKRemote(c agentconfig.Config, uploader *runnerpkg.ArtifactUploader) remoteAPI { +func newSDKRemote(c agentconfig.Config) remoteAPI { if c.API == nil { return nil } @@ -165,8 +149,7 @@ func newSDKRemote(c agentconfig.Config, uploader *runnerpkg.ArtifactUploader) re ClientSecret: strings.TrimSpace(c.API.Auth.ClientSecret), } } - client := sdk.NewClient(nil, cfg) - return sdkRemote{client: client, artifacts: uploader.Endpoint(cfg.BaseURL, client.Artifact)} + return sdkRemote{client: sdk.NewClient(nil, cfg)} } // runFunc runs one configuration until it is cancelled (daemon) or completes (one-shot). @@ -201,20 +184,10 @@ type reconciler struct { newRemote func(agentconfig.Config) remoteAPI // lookupEnv resolves ${env:NAME} placeholders (a test seam). lookupEnv func(string) (string, bool) - // resolvePolicy returns the policy root of an OCI or local policy source (downloading it - // into the shared cache) for the report inventory. - resolvePolicy policyResolver // pluginLib reads the agent library version of a prefetched plugin source (R76); // nil leaves the plugins report empty. pluginLib pluginLibFunc - // inventoryMemo caches the report inventory of OCI policy trees ("source\x00dir"). - inventoryMemo map[string]agentconfig.PolicyBundleReport - // artifacts is the process-wide artifact uploader (shared with the plugins' API helpers). - artifacts *runnerpkg.ArtifactUploader - // artifactMemo maps a policy tree digest to the digest of its uploaded artifact ("" when - // the API refused it for good). Owned by the reconciler goroutine. - artifactMemo map[string]string - now func() time.Time + now func() time.Time mu sync.Mutex // guards active, pending, cancelRun active *candidate @@ -258,12 +231,8 @@ func newReconciler(cmd *cobra.Command, configPath string, store *agentstate.Stor lookupEnv: os.LookupEnv, now: time.Now, loggedOnce: map[string]bool{}, - - inventoryMemo: map[string]agentconfig.PolicyBundleReport{}, - artifacts: runnerpkg.NewArtifactUploader(), - artifactMemo: map[string]string{}, + newRemote: newSDKRemote, } - rc.newRemote = func(c agentconfig.Config) remoteAPI { return newSDKRemote(c, rc.artifacts) } return rc } @@ -297,8 +266,6 @@ func (rc *reconciler) setBase(base *baseSnapshot) { } rc.remoteKey = key rc.fetchBackoffUntil, rc.reportBackoffUntil = time.Time{}, time.Time{} - // Artifacts uploaded to the previous API are not in this one. - rc.artifactMemo = map[string]string{} } id := cacheIdentity(base.declared) @@ -622,11 +589,8 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent aerr.runtime = runtime return nil, aerr } - var bundles []agentconfig.PolicyBundleReport - var trees []artifactTree var plugins []agentconfig.PluginReport if rcfg.Mode != agentconfig.ModeOff { - bundles, trees = rc.sourceReports(ctx, runtime) plugins = rc.pluginReports(ctx, runtime) } @@ -645,8 +609,6 @@ func (rc *reconciler) prepare(ctx context.Context, base *baseSnapshot, ov *agent runtime: runtime, digest: digest, identity: candidateIdentity(declared), - bundles: bundles, - trees: trees, warnings: append(append([]agentconfig.FieldError{}, part.warnings...), envWarnings...), plugins: plugins, }, nil diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 3d4aea7..91bff00 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -3,8 +3,6 @@ package cmd import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -12,7 +10,6 @@ import ( "path/filepath" "reflect" "runtime" - "slices" "strings" "sync" "testing" @@ -35,8 +32,6 @@ type fakeRemote struct { reportErr func(n int, r agentconfig.Report) error gets []string reports []agentconfig.Report - uploads []string - uploadErr func(n int) error } func (f *fakeRemote) Get(_ context.Context, ifNoneMatch string) (*sdk.AgentConfigResult, error) { @@ -68,26 +63,6 @@ func (f *fakeRemote) Report(_ context.Context, _ uuid.UUID, r agentconfig.Report return nil } -// UploadArtifact records an artifact upload; uploadErr scripts failures. -func (f *fakeRemote) UploadArtifact(_ context.Context, mediaType string, content []byte) (string, error) { - f.mu.Lock() - defer f.mu.Unlock() - f.uploads = append(f.uploads, mediaType) - if f.uploadErr != nil { - if err := f.uploadErr(len(f.uploads)); err != nil { - return "", err - } - } - sum := sha256.Sum256(content) - return "sha256:" + hex.EncodeToString(sum[:]), nil -} - -func (f *fakeRemote) uploadCount() int { - f.mu.Lock() - defer f.mu.Unlock() - return len(f.uploads) -} - // publish sets a new overlay revision with an opaque ETag. func (f *fakeRemote) publish(rev int64, overlay string) { f.mu.Lock() @@ -441,29 +416,18 @@ func TestReport_OversizedIsTruncated(t *testing.T) { "ssh": {Source: "ghcr.io/x/ssh:v1", Config: map[string]string{"blob": strings.Repeat("x", n)}}, }}) } - files := make([]agentconfig.PolicyFileReport, 20000) // ~3 MiB of file list - for i := range files { - files[i] = agentconfig.PolicyFileReport{Path: fmt.Sprintf("policies/m%05d.rego", i), SHA256: strings.Repeat("a", 64), Package: "compliance_framework.m"} - } - bundles := func() []agentconfig.PolicyBundleReport { - return []agentconfig.PolicyBundleReport{{Source: "ghcr.io/x/policies:v1", Digest: "sha256:t", ArtifactDigest: "sha256:a", Files: slices.Clone(files)}} - } - - // Dropping the file lists is enough: base is kept. - report := agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(1 << 19), Effective: config(1 << 19), PolicyBundles: bundles()} + // 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 { - t.Fatalf("expected a truncated report under the target, got truncated=%v size=%d", report.Truncated, len(body)) - } - if len(report.PolicyBundles[0].Files) != 0 || report.PolicyBundles[0].ArtifactDigest != "sha256:a" || string(report.Base) == "{}" { - t.Fatalf("expected the file lists dropped and base kept: %+v", report.PolicyBundles[0]) + 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)) } - // Then base is dropped. - report = agentconfig.Report{Mode: "apply_safe", Status: "applied", Base: config(2 << 20), Effective: config(2 << 20), PolicyBundles: bundles()} + // 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) diff --git a/cmd/report.go b/cmd/report.go index 92db86b..d2dbc4e 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -84,7 +84,6 @@ func (rc *reconciler) maybeReport(ctx context.Context, active *candidate, outcom if rc.now().Before(rc.reportBackoffUntil) { return } - rc.uploadArtifacts(ctx, active) report := rc.buildReport(active, outcome, rcfg) body, fingerprint, err := fitReport(&report, false) if err != nil { @@ -144,7 +143,6 @@ func (rc *reconciler) buildReport(active *candidate, outcome *applyError, rcfg a Base: marshalRaw(agentconfig.Redact(active.base.declared, opts...)), Effective: marshalRaw(agentconfig.Redact(active.declared, opts...)), EffectiveDigest: active.digest, - PolicyBundles: rc.withArtifactDigests(active.bundles), Warnings: active.warnings, RemoteConfig: &rcfg, Plugins: active.plugins, @@ -184,14 +182,13 @@ func truncateString(s string, n int) string { } // fitReport encodes the report, shrinking it to reportTargetBytes when needed (or always when -// force is set, for a resend after a 413): first the policy bundle file lists are dropped, then -// base is dropped. Any step sets Truncated. It returns the body and its fingerprint. +// 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){dropReportFileLists, dropReportBase} + steps := []func(*agentconfig.Report){dropReportBase} for _, step := range steps { if !force && len(body) <= reportTargetBytes { break @@ -206,12 +203,6 @@ func fitReport(report *agentconfig.Report, force bool) ([]byte, string, error) { return body, hex.EncodeToString(sum[:]), nil } -func dropReportFileLists(report *agentconfig.Report) { - for i := range report.PolicyBundles { - report.PolicyBundles[i].Files = []agentconfig.PolicyFileReport{} - } -} - func dropReportBase(report *agentconfig.Report) { report.Base = json.RawMessage(`{}`) } diff --git a/internal/policytree/policytree.go b/internal/policytree/policytree.go deleted file mode 100644 index c0042b8..0000000 --- a/internal/policytree/policytree.go +++ /dev/null @@ -1,135 +0,0 @@ -// Package policytree reads policy trees from disk, inventories them and archives them. It is -// the one place that decides which files a policy tree has, so every consumer sees the same -// tree: the report inventory, and the policy bundle artifacts uploaded at configuration time -// (the reconciler) and at evaluation time (the plugins' API helper). Uploading the same -// directory from either place therefore produces the same bytes, and the API assigns the -// same artifact digest (R62). -package policytree - -import ( - "archive/tar" - "bytes" - "crypto/sha256" - "encoding/hex" - "io/fs" - "maps" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/compliance-framework/api/pkg/agentconfig" - "github.com/open-policy-agent/opa/v1/ast" -) - -// ReadTree reads the regular files under dir, keyed by their slash-separated path relative -// to dir. dir itself may be a symlink (for example /etc/ccf/policies -> a versioned -// directory); symlinks inside the tree are skipped and returned, as OPA's bundle loader -// skips them too. Other non-regular files are ignored. -func ReadTree(dir string) (files map[string][]byte, skipped []string, err error) { - files = map[string][]byte{} - root, err := filepath.EvalSymlinks(dir) - if err != nil { - return nil, nil, err - } - err = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(root, p) - if err != nil { - return err - } - rel = filepath.ToSlash(rel) - if d.Type()&fs.ModeSymlink != 0 { - skipped = append(skipped, rel) - return nil - } - if d.IsDir() || !d.Type().IsRegular() { - return nil - } - raw, err := os.ReadFile(p) - if err != nil { - return err - } - files[rel] = raw - return nil - }) - if err != nil { - return nil, nil, err - } - return files, skipped, nil -} - -// TarFiles archives files as regular entries in path order, with a fixed mode and no -// times, so the same file map always produces the same bytes. (The API canonicalizes the -// archive anyway; determinism here makes the local upload cache hit.) -func TarFiles(files map[string][]byte) ([]byte, error) { - paths := make([]string, 0, len(files)) - for p := range files { - paths = append(paths, p) - } - slices.Sort(paths) - - var buf bytes.Buffer - tw := tar.NewWriter(&buf) - for _, p := range paths { - content := files[p] - if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: p, Mode: 0o644, Size: int64(len(content))}); err != nil { - return nil, err - } - if _, err := tw.Write(content); err != nil { - return nil, err - } - } - if err := tw.Close(); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - -// TarDirectory is ReadTree followed by TarFiles. -func TarDirectory(dir string) ([]byte, error) { - files, _, err := ReadTree(dir) - if err != nil { - return nil, err - } - return TarFiles(files) -} - -// Inventory digests and lists the policy tree at dir (an OCI or local policy source) for the -// config report: the tree digest (agentconfig.BundleTreeDigest) and every file with its -// SHA-256 and, for a Rego module, its package. -func Inventory(dir string) (string, []agentconfig.PolicyFileReport, error) { - files, _, err := ReadTree(dir) - if err != nil { - return "", nil, err - } - return agentconfig.BundleTreeDigest(files), inventory(files), nil -} - -func inventory(files map[string][]byte) []agentconfig.PolicyFileReport { - out := make([]agentconfig.PolicyFileReport, 0, len(files)) - for _, p := range slices.Sorted(maps.Keys(files)) { - sum := sha256.Sum256(files[p]) - r := agentconfig.PolicyFileReport{Path: p, SHA256: hex.EncodeToString(sum[:])} - if strings.HasSuffix(p, ".rego") { - r.Package = packageOf(p, files[p]) - } - out = append(out, r) - } - return out -} - -// packageOf returns the package of a Rego module without the leading "data.", parsing it as -// Rego v1, then as Rego v0, as plugins on either OPA major would load it. It is "" when the -// module does not parse. -func packageOf(p string, src []byte) string { - for _, v := range []ast.RegoVersion{ast.RegoV1, ast.RegoV0} { - mod, err := ast.ParseModuleWithOpts(p, string(src), ast.ParserOptions{RegoVersion: v}) - if err == nil && mod != nil && mod.Package != nil { - return strings.TrimPrefix(mod.Package.Path.String(), "data.") - } - } - return "" -} diff --git a/internal/policytree/policytree_test.go b/internal/policytree/policytree_test.go deleted file mode 100644 index 4534abd..0000000 --- a/internal/policytree/policytree_test.go +++ /dev/null @@ -1,147 +0,0 @@ -package policytree - -import ( - "archive/tar" - "bytes" - "crypto/sha256" - "encoding/hex" - "io" - "os" - "path/filepath" - "runtime" - "testing" - - "github.com/compliance-framework/api/pkg/agentconfig" -) - -func write(t *testing.T, path, content string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestReadTree_FollowsRootSymlinkSkipsInner(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks need privileges on Windows") - } - base := t.TempDir() - tree := filepath.Join(base, "v2") - write(t, filepath.Join(tree, "a.rego"), "a") - write(t, filepath.Join(tree, "sub", "b.rego"), "b") - write(t, filepath.Join(base, "outside.rego"), "secret") - if err := os.Symlink(filepath.Join(base, "outside.rego"), filepath.Join(tree, "link.rego")); err != nil { - t.Fatal(err) - } - if err := os.Symlink("v2", filepath.Join(base, "current")); err != nil { - t.Fatal(err) - } - - files, skipped, err := ReadTree(filepath.Join(base, "current")) - if err != nil { - t.Fatal(err) - } - if len(files) != 2 || string(files["a.rego"]) != "a" || string(files["sub/b.rego"]) != "b" { - t.Fatalf("files = %v", files) - } - if len(skipped) != 1 || skipped[0] != "link.rego" { - t.Fatalf("skipped = %v", skipped) - } - - // The symlinked root and the directory itself archive to the same bytes. - viaLink, err := TarDirectory(filepath.Join(base, "current")) - if err != nil { - t.Fatal(err) - } - direct, err := TarDirectory(tree) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(viaLink, direct) { - t.Fatal("a symlinked root must archive like the directory it points to") - } -} - -func TestTarFiles_DeterministicAndSorted(t *testing.T) { - files := map[string][]byte{"z.rego": []byte("z"), "a/b.rego": []byte("b"), "m.json": []byte("{}")} - first, err := TarFiles(files) - if err != nil { - t.Fatal(err) - } - for range 5 { - again, err := TarFiles(map[string][]byte{"m.json": []byte("{}"), "z.rego": []byte("z"), "a/b.rego": []byte("b")}) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(first, again) { - t.Fatal("the same file map must archive to the same bytes") - } - } - tr := tar.NewReader(bytes.NewReader(first)) - var names []string - for { - h, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatal(err) - } - if h.Typeflag != tar.TypeReg || h.Mode != 0o644 || !h.ModTime.IsZero() && h.ModTime.Unix() != 0 { - t.Fatalf("unexpected header %+v", h) - } - names = append(names, h.Name) - } - if len(names) != 3 || names[0] != "a/b.rego" || names[1] != "m.json" || names[2] != "z.rego" { - t.Fatalf("entries = %v", names) - } -} - -func TestInventory(t *testing.T) { - dir := t.TempDir() - write(t, filepath.Join(dir, "banner.rego"), "package compliance_framework.banner\n\nviolation contains {\"id\": \"b\"} if not input.banner\n") - write(t, filepath.Join(dir, "legacy", "v0.rego"), "package compliance_framework.legacy\n\nviolation[{\"id\": \"l\"}] { input.bad }\n") - write(t, filepath.Join(dir, "broken.rego"), "package\n") - write(t, filepath.Join(dir, "data.json"), "{}") - - digest, files, err := Inventory(dir) - if err != nil { - t.Fatal(err) - } - tree, _, err := ReadTree(dir) - if err != nil { - t.Fatal(err) - } - if want := agentconfig.BundleTreeDigest(tree); digest != want { - t.Fatalf("digest = %q, want the tree digest %q", digest, want) - } - want := map[string]string{ - "banner.rego": "compliance_framework.banner", - "broken.rego": "", - "data.json": "", - "legacy/v0.rego": "compliance_framework.legacy", - } - if len(files) != len(want) { - t.Fatalf("files = %+v", files) - } - for i, f := range files { - if i > 0 && files[i-1].Path >= f.Path { - t.Fatalf("files must be sorted by path: %+v", files) - } - pkg, ok := want[f.Path] - if !ok || f.Package != pkg { - t.Fatalf("file %s: package %q, want %q (known %v)", f.Path, f.Package, pkg, ok) - } - sum := sha256.Sum256(tree[f.Path]) - if f.SHA256 != hex.EncodeToString(sum[:]) { - t.Fatalf("file %s: sha256 %s", f.Path, f.SHA256) - } - } - - if _, _, err := Inventory(filepath.Join(dir, "missing")); err == nil { - t.Fatal("a missing tree must be an error") - } -} diff --git a/runner/policy_artifacts.go b/runner/policy_artifacts.go index 93f3140..bfeb9d3 100644 --- a/runner/policy_artifacts.go +++ b/runner/policy_artifacts.go @@ -1,17 +1,20 @@ package runner import ( + "archive/tar" + "bytes" "context" "crypto/sha256" "encoding/hex" "errors" "fmt" + "io/fs" "net/http" + "os" "path/filepath" "sync" "time" - "github.com/compliance-framework/agent/internal/policytree" "github.com/compliance-framework/agent/runner/proto" "github.com/compliance-framework/api/sdk" "github.com/compliance-framework/api/sdk/types" @@ -22,10 +25,6 @@ import ( // them once per evaluation, sends the evidence with the digests the API returns, and never // forwards the raw data. The API canonicalises and hashes; the agent only hashes locally to // avoid re-uploading content it already sent. -// -// The same artifact store is the agent's one channel for policy sources (R62): the -// reconciler uploads the policy trees it reports, through the same ArtifactUploader, so a -// tree uploaded at configuration time is not uploaded again at evaluation time. const ( artifactUploadAttempts = 3 @@ -35,99 +34,107 @@ const ( artifactsUnsupportedRecheck = 10 * time.Minute ) -// ErrArtifactsUnsupported means the API predates artifact storage. -var ErrArtifactsUnsupported = errors.New("the API does not support policy artifacts") +// errArtifactsUnsupported means the API predates artifact storage. +var errArtifactsUnsupported = errors.New("the API does not support policy artifacts") -// ArtifactClient is the artifact route of one API (sdk.Client.Artifact). -type ArtifactClient interface { - Upload(ctx context.Context, mediaType string, content []byte) (*sdk.ArtifactInfo, error) -} - -// ArtifactUploader uploads artifacts for the whole agent process: the reconciler and the API -// helper of every plugin run share one, so content is uploaded once per API whoever needs -// it first. It remembers what each API already has (by a local hash of the bytes), retries -// temporary failures, and backs off from an API that predates artifact storage. It is safe -// for concurrent use. -type ArtifactUploader struct { - retryDelay time.Duration - now func() time.Time +type artifactUploader struct { + client *sdk.Client + policyPaths map[string]struct{} + retryDelay time.Duration mu sync.Mutex - uploaded map[artifactKey]string // what each API already has -> its digest - unsupportedUntil map[string]time.Time // per API + uploaded map[[sha256.Size]byte]string // local hash of uploaded bytes -> API digest + unsupportedUntil time.Time + now func() time.Time } -type artifactKey struct { - api string - local [sha256.Size]byte +func newArtifactUploader(client *sdk.Client) *artifactUploader { + return &artifactUploader{ + client: client, + policyPaths: map[string]struct{}{}, + retryDelay: 250 * time.Millisecond, + uploaded: map[[sha256.Size]byte]string{}, + now: time.Now, + } } -// NewArtifactUploader returns an empty uploader. -func NewArtifactUploader() *ArtifactUploader { - return &ArtifactUploader{ - retryDelay: 250 * time.Millisecond, - now: time.Now, - uploaded: map[artifactKey]string{}, - unsupportedUntil: map[string]time.Time{}, +// evaluationKey identifies an evaluation: by its stream Id when it has one, otherwise by +// what it depended on, since evidence from one evaluation sent in a batch arrives over gRPC +// as separate copies. +func evaluationKey(e *proto.PolicyEvaluation) string { + if id := e.GetId(); id != "" { + return "id:" + id + } + h := sha256.New() + for _, part := range [][]byte{[]byte(e.GetPolicyPath()), e.GetInput(), e.GetPolicyData()} { + _, _ = fmt.Fprintf(h, "%d:", len(part)) + _, _ = h.Write(part) } + return "content:" + hex.EncodeToString(h.Sum(nil)) } -// Endpoint binds the uploader to one API: api identifies it (its base URL) and client is its -// artifact route. Endpoints of the same api share what was uploaded. -func (u *ArtifactUploader) Endpoint(api string, client ArtifactClient) *ArtifactEndpoint { - return &ArtifactEndpoint{u: u, api: api, client: client} -} +func (u *artifactUploader) storeEvaluation(ctx context.Context, evaluation *proto.PolicyEvaluation) (*types.PolicyArtifacts, error) { + u.mu.Lock() + unsupported := u.now().Before(u.unsupportedUntil) + u.mu.Unlock() + if unsupported { + return nil, errArtifactsUnsupported + } -// ArtifactEndpoint uploads to one API through a shared ArtifactUploader. -type ArtifactEndpoint struct { - u *ArtifactUploader - api string - client ArtifactClient -} + policyPath := filepath.Clean(evaluation.GetPolicyPath()) + if _, ok := u.policyPaths[policyPath]; !ok { + return nil, fmt.Errorf("policy path %q is not one of the plugin's policy bundles", evaluation.GetPolicyPath()) + } + if len(evaluation.GetInput()) == 0 { + return nil, errors.New("the evaluation has no input data") + } + + bundle, err := tarDirectory(policyPath) + if err != nil { + return nil, fmt.Errorf("package policy bundle: %w", err) + } -// unsupported reports whether the API was found not to support artifacts recently. -func (e *ArtifactEndpoint) unsupported() bool { - e.u.mu.Lock() - defer e.u.mu.Unlock() - return e.u.now().Before(e.u.unsupportedUntil[e.api]) + refs := &types.PolicyArtifacts{} + if refs.BundleDigest, err = u.upload(ctx, sdk.ArtifactMediaTypePolicyBundle, bundle); err != nil { + return nil, fmt.Errorf("upload policy bundle: %w", err) + } + if refs.InputDigest, err = u.upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetInput()); err != nil { + return nil, fmt.Errorf("upload input data: %w", err) + } + if len(evaluation.GetPolicyData()) > 0 { + if refs.PolicyDataDigest, err = u.upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetPolicyData()); err != nil { + return nil, fmt.Errorf("upload policy data: %w", err) + } + } + return refs, nil } -// Upload stores content unless this agent already uploaded the same bytes to this API, -// retrying temporary failures, and returns the digest the API assigned. A 404 or 405 means -// the API predates artifacts: ErrArtifactsUnsupported, and no upload to it is attempted for -// a while. Other failures are returned as they are (*sdk.ArtifactStatusError for a status). -func (e *ArtifactEndpoint) Upload(ctx context.Context, mediaType string, content []byte) (string, error) { - u := e.u - key := artifactKey{api: e.api, local: sha256.Sum256(append([]byte(mediaType+"\x00"), content...))} +// upload stores content unless this agent already uploaded the same bytes, retrying +// temporary failures, and returns the digest the API assigned. +func (u *artifactUploader) upload(ctx context.Context, mediaType string, content []byte) (string, error) { + local := sha256.Sum256(append([]byte(mediaType+"\x00"), content...)) u.mu.Lock() - digest, ok := u.uploaded[key] - unsupported := u.now().Before(u.unsupportedUntil[e.api]) + digest, ok := u.uploaded[local] u.mu.Unlock() if ok { return digest, nil } - if unsupported { - return "", ErrArtifactsUnsupported - } - if e.client == nil { - return "", errors.New("no API client") - } var err error for attempt := 1; attempt <= artifactUploadAttempts; attempt++ { var info *sdk.ArtifactInfo - info, err = e.client.Upload(ctx, mediaType, content) + info, err = u.client.Artifact.Upload(ctx, mediaType, content) if err == nil { - u.remember(key, info.Digest) + u.remember(local, info.Digest) return info.Digest, nil } var statusErr *sdk.ArtifactStatusError if errors.As(err, &statusErr) && (statusErr.StatusCode == http.StatusNotFound || statusErr.StatusCode == http.StatusMethodNotAllowed) { u.mu.Lock() - u.unsupportedUntil[e.api] = u.now().Add(artifactsUnsupportedRecheck) + u.unsupportedUntil = u.now().Add(artifactsUnsupportedRecheck) u.mu.Unlock() - return "", ErrArtifactsUnsupported + return "", errArtifactsUnsupported } if !retryable(ctx, err) || attempt == artifactUploadAttempts { break @@ -141,62 +148,13 @@ func (e *ArtifactEndpoint) Upload(ctx context.Context, mediaType string, content return "", err } -func (u *ArtifactUploader) remember(key artifactKey, digest string) { +func (u *artifactUploader) remember(local [sha256.Size]byte, digest string) { u.mu.Lock() defer u.mu.Unlock() if len(u.uploaded) >= artifactCacheLimit { - u.uploaded = map[artifactKey]string{} + u.uploaded = map[[sha256.Size]byte]string{} } - u.uploaded[key] = digest -} - -// evaluationKey identifies an evaluation: by its stream Id when it has one, otherwise by -// what it depended on, since evidence from one evaluation sent in a batch arrives over gRPC -// as separate copies. -func evaluationKey(e *proto.PolicyEvaluation) string { - if id := e.GetId(); id != "" { - return "id:" + id - } - h := sha256.New() - for _, part := range [][]byte{[]byte(e.GetPolicyPath()), e.GetInput(), e.GetPolicyData()} { - _, _ = fmt.Fprintf(h, "%d:", len(part)) - _, _ = h.Write(part) - } - return "content:" + hex.EncodeToString(h.Sum(nil)) -} - -// storeEvaluation uploads what one evaluation used: the policy bundle at the policy path the -// plugin was given (see WithPolicyPaths), its input and its policy data. -func (h *apiHelper) storeEvaluation(ctx context.Context, evaluation *proto.PolicyEvaluation) (*types.PolicyArtifacts, error) { - if h.artifacts.unsupported() { - return nil, ErrArtifactsUnsupported - } - policyPath := filepath.Clean(evaluation.GetPolicyPath()) - if _, ok := h.policyPaths[policyPath]; !ok { - return nil, fmt.Errorf("policy path %q is not one of the plugin's policy bundles", evaluation.GetPolicyPath()) - } - if len(evaluation.GetInput()) == 0 { - return nil, errors.New("the evaluation has no input data") - } - - bundle, err := policytree.TarDirectory(policyPath) - if err != nil { - return nil, fmt.Errorf("package policy bundle: %w", err) - } - - refs := &types.PolicyArtifacts{} - if refs.BundleDigest, err = h.artifacts.Upload(ctx, sdk.ArtifactMediaTypePolicyBundle, bundle); err != nil { - return nil, fmt.Errorf("upload policy bundle: %w", err) - } - if refs.InputDigest, err = h.artifacts.Upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetInput()); err != nil { - return nil, fmt.Errorf("upload input data: %w", err) - } - if len(evaluation.GetPolicyData()) > 0 { - if refs.PolicyDataDigest, err = h.artifacts.Upload(ctx, sdk.ArtifactMediaTypeJSON, evaluation.GetPolicyData()); err != nil { - return nil, fmt.Errorf("upload policy data: %w", err) - } - } - return refs, nil + u.uploaded[local] = digest } // retryable reports whether an upload failure may succeed if repeated: server errors, rate @@ -211,3 +169,38 @@ func retryable(ctx context.Context, err error) bool { } return true } + +// tarDirectory archives the regular files under dir. The API canonicalises the archive, +// so file order, modes and times here do not affect the digest. +func tarDirectory(dir string) ([]byte, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if !d.Type().IsRegular() { + return fmt.Errorf("%s is not a regular file", path) + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{Typeflag: tar.TypeReg, Name: filepath.ToSlash(rel), Mode: 0o644, Size: int64(len(content))}); err != nil { + return err + } + _, err = tw.Write(content) + return err + }) + if err != nil { + return nil, err + } + if err := tw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/runner/policy_artifacts_test.go b/runner/policy_artifacts_test.go index 7527446..99bd534 100644 --- a/runner/policy_artifacts_test.go +++ b/runner/policy_artifacts_test.go @@ -17,7 +17,6 @@ import ( "testing" "time" - "github.com/compliance-framework/agent/internal/policytree" policyManager "github.com/compliance-framework/agent/policy-manager" "github.com/compliance-framework/agent/runner/proto" "github.com/compliance-framework/api/sdk" @@ -77,7 +76,7 @@ func newTestHelper(t *testing.T, api *fakeAPI, policyPaths ...string) *apiHelper t.Cleanup(server.Close) client := sdk.NewClient(server.Client(), &sdk.Config{BaseURL: server.URL}) helper := NewApiHelper(hclog.NewNullLogger(), client, map[string]string{"_agent": "test"}, "test-plugin", WithPolicyPaths(policyPaths)) - helper.uploader.retryDelay = time.Millisecond + helper.artifacts.retryDelay = time.Millisecond return helper } @@ -163,7 +162,7 @@ func TestCreateEvidenceUnderAnOldAPISendsEvidenceWithoutArtifacts(t *testing.T) assert.Len(t, api.uploads, 1) // ...but checks again later, so an upgraded API is picked up. - helper.uploader.now = func() time.Time { return time.Now().Add(artifactsUnsupportedRecheck + time.Minute) } + helper.artifacts.now = func() time.Time { return time.Now().Add(artifactsUnsupportedRecheck + time.Minute) } require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ evidenceFor("three", &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), })) @@ -353,36 +352,3 @@ violation contains {"id": "wget-version"} if input.wget != data.allowed_versions assert.NotEmpty(t, refs["policy-data-digest"]) assert.True(t, strings.HasPrefix(refs["bundle-digest"].(string), "sha256:")) } - -// TestSharedUploaderUploadsOncePerAPI: helpers sharing the process-wide uploader do not -// upload what another one (or the reconciler) already uploaded to the same API (R62). -func TestSharedUploaderUploadsOncePerAPI(t *testing.T) { - bundle := writeBundle(t, "a") - api := &fakeAPI{} - server := httptest.NewServer(api) - t.Cleanup(server.Close) - client := sdk.NewClient(server.Client(), &sdk.Config{BaseURL: server.URL}) - shared := NewArtifactUploader() - - send := func(apiURL, title string) { - helper := NewApiHelper(hclog.NewNullLogger(), client, nil, "p", WithPolicyPaths([]string{bundle}), WithArtifactUploader(shared, apiURL)) - require.NoError(t, helper.CreateEvidence(context.Background(), []*proto.Evidence{ - evidenceFor(title, &proto.PolicyEvaluation{PolicyPath: bundle, Input: []byte(`{}`)}), - })) - } - send(server.URL, "one") - send(server.URL, "two") - assert.Len(t, api.uploads, 2, "bundle and input once for both helpers") - - // The reconciler's endpoint for the same API sees them too. - tarball, err := policytree.TarDirectory(bundle) - require.NoError(t, err) - digest, err := shared.Endpoint(server.URL, client.Artifact).Upload(context.Background(), sdk.ArtifactMediaTypePolicyBundle, tarball) - require.NoError(t, err) - assert.NotEmpty(t, digest) - assert.Len(t, api.uploads, 2) - - // Another API does not have them. - send("http://other.example", "three") - assert.Len(t, api.uploads, 4) -} diff --git a/runner/result.go b/runner/result.go index ca48442..270ed7d 100644 --- a/runner/result.go +++ b/runner/result.go @@ -16,15 +16,10 @@ type apiHelper struct { client *sdk.Client agentLabels map[string]string pluginName string - artifacts *ArtifactEndpoint - // policyPaths are the policy bundle paths the plugin was given (cleaned). - policyPaths map[string]struct{} + artifacts *artifactUploader // evidenceProps are appended to every evidence the plugin creates. evidenceProps []types.Property - uploader *ArtifactUploader - apiURL string - // pluginSource and policySources are where the plugin and its policy bundles came from, // recorded on evidence as _plugin_source / _plugin_digest and _policy_source / // _policy_digest. @@ -80,18 +75,7 @@ type ApiHelperOption func(*apiHelper) func WithPolicyPaths(paths []string) ApiHelperOption { return func(h *apiHelper) { for _, path := range paths { - h.policyPaths[filepath.Clean(path)] = struct{}{} - } - } -} - -// WithArtifactUploader shares the process-wide uploader (R62), so what the reconciler or -// another plugin run already uploaded to the API at apiURL is not uploaded again. Without it -// the helper uses an uploader of its own. -func WithArtifactUploader(u *ArtifactUploader, apiURL string) ApiHelperOption { - return func(h *apiHelper) { - if u != nil { - h.uploader, h.apiURL = u, apiURL + h.artifacts.policyPaths[filepath.Clean(path)] = struct{}{} } } } @@ -112,21 +96,13 @@ func NewApiHelper(logger hclog.Logger, client *sdk.Client, agentLabels map[strin client: client, agentLabels: agentLabels, pluginName: pluginName, - policyPaths: map[string]struct{}{}, + artifacts: newArtifactUploader(client), policySources: map[string]Source{}, } for _, opt := range opts { opt(h) } - if h.uploader == nil { - h.uploader = NewArtifactUploader() - } - var artifacts ArtifactClient - if client != nil { - artifacts = client.Artifact - } - h.artifacts = h.uploader.Endpoint(h.apiURL, artifacts) return h } @@ -192,9 +168,9 @@ func (s *apiEvidenceSender) outcome(evaluation *proto.PolicyEvaluation) evaluati s.h.logger.Warn("Sending evidence without policy artifacts; it cannot be played back", "error", unknownEvaluation(evaluation.GetId())) } else { - refs, err := s.h.storeEvaluation(s.ctx, evaluation) + refs, err := s.h.artifacts.storeEvaluation(s.ctx, evaluation) switch { - case errors.Is(err, ErrArtifactsUnsupported): + case errors.Is(err, errArtifactsUnsupported): if !s.unsupported { s.unsupported = true s.h.logger.Warn("The API does not support policy artifacts; evidence is sent without them and cannot be played back. Upgrade the API.") From 1a1a667c89b105d2454c4fe26c586075d0733988 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 11:44:40 -0300 Subject: [PATCH 45/47] chore: pin api b53be8f Picks up compliance-framework/api#465 at b53be8f: agentconfig redaction now also masks by value (URL passwords, private keys, password= assignments, known token formats) and masks literal text mixed with ${env:...} under secret-like keys, and remote_config.mode defaults to report. The report and heartbeat digests come from agentconfig.Redact/Digest, so they follow the API. The report tests now pin that a dsn mixing literal text and placeholders is masked and that a password in a URL is masked by value. Co-Authored-By: Claude Opus 5.5 --- cmd/remote_test.go | 18 ++++++++++++++++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 91bff00..107f2b8 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -207,6 +207,7 @@ func TestStartupReport_RedactsAndDescribes(t *testing.T) { 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, `{}`) @@ -227,6 +228,9 @@ func TestStartupReport_RedactsAndDescribes(t *testing.T) { 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) } @@ -815,8 +819,18 @@ func TestEnvPlaceholders(t *testing.T) { if cfg["host"] != "db.internal" || cfg["dsn"] != "pg://db.internal:5432/db" { t.Fatalf("placeholders not resolved whole/embedded: %#v", cfg) } - if !strings.Contains(string(h.remote.lastReport(t).Effective), "${env:HOST}") { - t.Fatal("the report must carry the unresolved placeholder") + 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" diff --git a/go.mod b/go.mod index f5506e1..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.1-0.20261002112713-cce6baf38414 + 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 diff --git a/go.sum b/go.sum index 9383c38..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.1-0.20261002112713-cce6baf38414 h1:bGCccOmZhFTQhqgCldPy7U/r1w3k4RqEnKd0L+Mpit8= -github.com/compliance-framework/api v0.20.1-0.20261002112713-cce6baf38414/go.mod h1:TrmFnqr+UBvgvKWspYVEnmZSLMyoPbc2i2/jPK7EUww= +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= From d0fbdaa6570a0f4cfe46c3774709013443b87729 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 11:46:09 -0300 Subject: [PATCH 46/47] fix!: remote_config.mode defaults to report With api.auth credentials and no remote_config.mode, the API's agentconfig.RemoteConfig.Normalize now defaults the mode to report instead of apply_safe: the agent reports its configuration but never fetches or applies an overlay (not even a cached applied one) until the host owner sets apply_safe or apply_all. The agent delegates the default to the API package; this pins it with a test (with and without a remote_config block, after an overlay was applied under apply_safe) and updates the README, configuration docs and ADR 0003. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 ++-- cmd/remote_test.go | 41 ++++++++++++++++++++++++++ docs/adr/0003-remote-config-overlay.md | 6 ++++ docs/configuration.md | 6 ++-- 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9b1580f..2c5b2d7 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ Plugins never receive `CCF_API_AUTH_*` variables. ### Remote configuration and state -With `api.auth` credentials the agent reports its configuration to the API and can apply a configuration overlay -stored there (`remote_config`), including `${env:NAME}` placeholders in plugin config. Each instance keeps a stable ID and a cache in a state directory (`--state-dir` / `CCF_STATE_DIR`; +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). diff --git a/cmd/remote_test.go b/cmd/remote_test.go index 107f2b8..b54eb34 100644 --- a/cmd/remote_test.go +++ b/cmd/remote_test.go @@ -313,6 +313,47 @@ func TestModes_ReportAndOff(t *testing.T) { 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) diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 59b69e6..604bb15 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -40,6 +40,12 @@ 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 diff --git a/docs/configuration.md b/docs/configuration.md index d36b4e5..52f9839 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -221,15 +221,15 @@ controls it. It is **set locally only** (file, host environment, CLI flags), nev ```yaml remote_config: - mode: apply_safe # off | report | apply_safe | apply_all + 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 `apply_safe` when `api.auth` is set and `off` otherwise (no credentials always forces -`off`); `poll_interval` is `60s`; `trusted_sources` and `overridable_config_flags` are empty; +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. From 042e3d10a73113bbcb450dc5b8c8854e44c23776 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Fri, 2 Oct 2026 11:46:36 -0300 Subject: [PATCH 47/47] docs: drop the policy bundle inventory and describe redaction by value Remove the report-time policy-bundles inventory, tree digests and policy tree uploads from the configuration docs and ADR 0003, and restore the evaluation-time upload description in policy_artifacts.md to main. Report truncation now only drops base. Point the redaction description at the API's pkg/agentconfig as the source of truth and summarize it: secret-like keys, secret-looking values, and literal text mixed with placeholders under secret-like keys. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- docs/adr/0003-remote-config-overlay.md | 13 ++----------- docs/configuration.md | 22 +++++++++------------- docs/policy_artifacts.md | 8 ++------ 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 2c5b2d7..8b519ce 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,8 @@ 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. +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 diff --git a/docs/adr/0003-remote-config-overlay.md b/docs/adr/0003-remote-config-overlay.md index 604bb15..83fef82 100644 --- a/docs/adr/0003-remote-config-overlay.md +++ b/docs/adr/0003-remote-config-overlay.md @@ -68,16 +68,6 @@ reads the `github.com/compliance-framework/agent` version from each plugin binar (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. -### One channel for policy sources (R62) - -The UI needs to show the policy sources an instance loads, and the API never sees them. Rather than a second route, the -agent reuses the evidence artifact store: one process-wide `runner.ArtifactUploader` is shared by the reconciler and -every plugin's API helper, and one archiver (`internal/policytree`: `ReadTree` + `TarFiles`) serves both, so the -configuration-time and evaluation-time uploads of a tree are the same bytes and the same artifact. At report time the -reconciler uploads each reported tree once (memoized by tree digest per API) within the remote request timeout and -fills `artifact-digest` on its `policy-bundles[]` entry; the field survives report truncation. Failures leave it empty -and never reject or fail a revision. - ### Plugin environment filter go-plugin hands the whole host environment to plugins. The agent now sets `SkipHostEnv` and passes the host @@ -117,7 +107,8 @@ 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). + 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 52f9839..2832e23 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -202,8 +202,12 @@ redaction and the configuration digest always use the unresolved placeholder, so (R24). Plugin values set through viper environment variables (`CCF_PLUGINS_

_CONFIG_`, see the README) are masked as -`••••` in every report, as are keys that look like secrets (`secret`, `token`, `password`, `key`, `credential`, -`auth`) (R25). +`••••` 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 @@ -269,20 +273,12 @@ in order: the freshly fetched overlay, the cached applied overlay, the file alon 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. -### Sources for the UI (R62) - -Outside mode `off`, the configuration report inventories every policy source the instance's plugins load (each OCI or -local source) as `policy-bundles[]`: the source, its tree digest and its files (path, SHA-256 and, for a Rego module, -its package). The agent also uploads each tree as a policy bundle artifact and reports its `artifact-digest` next to the -tree digest, so the UI can show the sources. These are the same artifacts evidence references for playback: one -uploader serves both, and a tree is uploaded once. Uploads are best effort: a failure (an API without artifacts, a tree -over the API's size limit, a timeout) leaves `artifact-digest` empty and never rejects or fails a revision. Note that -artifacts are readable with `artifact:read`. When the report is too large, the file lists are dropped first, then the -`base` document; the digests are kept. +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 also lists the instance's plugins as `plugins[]` (`name`, `source`, `lib-version`), where `lib-version` is +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. diff --git a/docs/policy_artifacts.md b/docs/policy_artifacts.md index 3e0080c..6218352 100644 --- a/docs/policy_artifacts.md +++ b/docs/policy_artifacts.md @@ -27,12 +27,8 @@ The API does all canonicalisation and hashing; see its `docs/artifacts.md`. digests, before the next message arrives. The raw data is never forwarded with the evidence, and the agent never holds the whole batch in memory. -The agent only reads bundles at the policy paths it gave that plugin. Symlinks inside a -bundle are skipped, as OPA skips them. The agent remembers what it has already uploaded to -each API, so unchanged content is not uploaded again on later runs. The same process-wide uploader -serves the configuration report, which uploads the policy trees it names (see -`configuration.md`, "Sources for the UI"); a tree uploaded there is not uploaded again for -evidence, and both produce the same artifact digest. +The agent only reads bundles at the policy paths it gave that plugin. It remembers what it +has already uploaded, so unchanged content is not uploaded again on later runs. ## Plugins