diff --git a/README.md b/README.md index 25c287e..8eba50d 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,13 @@ agent_evidence: See [configuration](./docs/configuration.md) for more information. +The agent adds `_agent_config_hash` to plugin and agent evidence labels. The value is a deterministic SHA-256 hash of +the runtime plugin and agent evidence configuration, which prevents multiple unauthenticated agents using the fallback +`_agent=ccf` identity from writing to the same evidence seed when their configurations differ. + ### Environment variables -The agent can load specific configruation values from environment variables, which are prefixed with `CCF_` and the path +The agent can load specific configuration values from environment variables, which are prefixed with `CCF_` and the path in the config is specified using underscore-separated key. For example, to specify the `token` value in the GitHub config, you may set an environment variable `CCF_PLUGINS_GITHUB_CONFIG_TOKEN` diff --git a/cmd/agent.go b/cmd/agent.go index 6176550..7b8eefe 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "crypto/sha256" "encoding/base64" "encoding/json" "fmt" @@ -199,6 +200,7 @@ const RunnerV2ProtocolVersion int32 = 2 const AnnotationProtocolVersionKey = "org.ccf.plugin.protocol.version" const daemonCronStopTimeout = 30 * time.Second const agentEvidenceErrorArtifactMaxBytes = 1024 * 1024 +const agentConfigHashLabel = "_agent_config_hash" type pluginRunStatus string @@ -839,12 +841,141 @@ func agentIdentityLabel(config *agentConfig) string { func agentFoundationalLabels(config *agentConfig) map[string]string { return map[string]string{ - "_agent": agentIdentityLabel(config), - "tool": "ccf", - "type": "operations", + "_agent": agentIdentityLabel(config), + agentConfigHashLabel: agentConfigurationHash(config), + "tool": "ccf", + "type": "operations", } } +type normalizedAgentConfigForHash struct { + AgentEvidence normalizedAgentEvidenceConfigForHash `json:"agent_evidence"` + Plugins []normalizedAgentPluginForHash `json:"plugins"` +} + +type normalizedAgentEvidenceConfigForHash struct { + Enabled bool `json:"enabled"` + EmitOnRunCompletion bool `json:"emit_on_run_completion"` + Interval string `json:"interval"` +} + +type normalizedAgentPluginForHash struct { + Name string `json:"name"` + ProtocolVersion int32 `json:"protocol_version"` + Schedule string `json:"schedule"` + Source string `json:"source"` + Policies []string `json:"policies"` + Config map[string]string `json:"config,omitempty"` + Labels map[string]string `json:"labels,omitempty"` +} + +func agentConfigurationHash(config *agentConfig) string { + normalized := normalizedAgentConfigForHash{ + AgentEvidence: normalizedAgentEvidenceConfigForHash{ + Enabled: true, + EmitOnRunCompletion: true, + Interval: normalizedAgentEvidenceInterval(config), + }, + } + + if config != nil { + normalized.AgentEvidence.Enabled = config.agentEvidenceEnabled() + normalized.AgentEvidence.EmitOnRunCompletion = config.agentEvidenceEmitOnRunCompletion() + + pluginNames := make([]string, 0, len(config.Plugins)) + for pluginName := range config.Plugins { + pluginNames = append(pluginNames, pluginName) + } + sort.Strings(pluginNames) + + normalized.Plugins = make([]normalizedAgentPluginForHash, 0, len(pluginNames)) + for _, pluginName := range pluginNames { + pluginConfig := config.Plugins[pluginName] + normalizedPlugin := normalizedAgentPluginForHash{ + Name: pluginName, + Schedule: "* * * * *", + } + if pluginConfig != nil { + normalizedPlugin.ProtocolVersion = effectivePluginProtocolVersion(pluginConfig) + normalizedPlugin.Source = pluginConfig.Source + if pluginConfig.Schedule != nil { + normalizedPlugin.Schedule = *pluginConfig.Schedule + } + normalizedPlugin.Policies = make([]string, 0, len(pluginConfig.Policies)) + for _, policy := range pluginConfig.Policies { + normalizedPlugin.Policies = append(normalizedPlugin.Policies, string(policy)) + } + normalizedPlugin.Config = copyStringMap(pluginConfig.Config) + normalizedPlugin.Labels = copyStringMap(pluginConfig.Labels) + } + normalized.Plugins = append(normalized.Plugins, normalizedPlugin) + } + } + + payload, err := json.Marshal(normalized) + if err != nil { + sum := sha256.Sum256([]byte(err.Error())) + return fmt.Sprintf("%x", sum[:]) + } + sum := sha256.Sum256(payload) + return fmt.Sprintf("%x", sum[:]) +} + +func normalizedAgentEvidenceInterval(config *agentConfig) string { + if config == nil { + return time.Hour.String() + } + + interval, err := config.agentEvidenceInterval() + if err != nil { + if config.AgentEvidence == nil { + return "" + } + return strings.TrimSpace(config.AgentEvidence.Interval) + } + return interval.String() +} + +func copyStringMap(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + + output := make(map[string]string, len(input)) + for key, value := range input { + output[key] = value + } + return output +} + +func pluginEvidenceLabels(config *agentConfig, pluginName string, pluginConfig *agentPlugin) map[string]string { + return pluginEvidenceLabelsWithHash(config, pluginName, pluginConfig, agentConfigurationHash(config)) +} + +func pluginEvidenceLabelsWithHash(config *agentConfig, pluginName string, pluginConfig *agentPlugin, configHash string) map[string]string { + labels := map[string]string{ + "_agent": agentIdentityLabel(config), + "_plugin": pluginName, + } + if pluginConfig != nil { + for k, v := range pluginConfig.Labels { + labels[k] = v + } + } + labels[agentConfigHashLabel] = configHash + return labels +} + +func effectivePluginProtocolVersion(pluginConfig *agentPlugin) int32 { + if pluginConfig == nil { + return 0 + } + if pluginConfig.ProtocolVersion == 0 && !pluginConfig.protocolSet { + return DefaultProtocolVersion + } + return pluginConfig.ProtocolVersion +} + func maskClientID(clientID string) string { trimmed := strings.TrimSpace(clientID) if trimmed == "" { @@ -1186,6 +1317,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { pluginNames = append(pluginNames, pluginName) } sort.Strings(pluginNames) + configHash := agentConfigurationHash(config) for _, pluginName := range pluginNames { pluginConfig := config.Plugins[pluginName] @@ -1196,13 +1328,7 @@ func (ar *AgentRunner) runAllPlugins(ctx context.Context) error { Level: hclog.Level(config.logVerbosity()), }) - labels := map[string]string{ - "_agent": agentIdentityLabel(config), - "_plugin": pluginName, - } - for k, v := range pluginConfig.Labels { - labels[k] = v - } + labels := pluginEvidenceLabelsWithHash(config, pluginName, pluginConfig, configHash) source := ar.pluginLocations[pluginConfig.Source] @@ -1362,13 +1488,7 @@ func (ar *AgentRunner) runPlugin(ctx context.Context, name string, plugin *agent Level: hclog.Level(config.logVerbosity()), }) - labels := map[string]string{ - "_agent": agentIdentityLabel(config), - "_plugin": name, - } - for k, v := range plugin.Labels { - labels[k] = v - } + labels := pluginEvidenceLabelsWithHash(config, name, plugin, agentConfigurationHash(config)) pluginLogger.Debug("Running plugin", "source", pluginExecutable, "protocol_version", plugin.ProtocolVersion) @@ -1516,11 +1636,7 @@ func (ar *AgentRunner) buildAgentRunEvidence(now time.Time) (*agentEvidenceCreat description := formatAgentEvidenceDescription(snapshot) remarks := formatAgentEvidenceRemarks(snapshot) labels := agentFoundationalLabels(config) - evidenceUUID, err := sdk.SeededUUID(map[string]string{ - "type": labels["type"], - "_agent": labels["_agent"], - "tool": labels["tool"], - }) + evidenceUUID, err := sdk.SeededUUID(labels) if err != nil { return nil, err } diff --git a/cmd/agent_test.go b/cmd/agent_test.go index 01ab4e1..164043c 100644 --- a/cmd/agent_test.go +++ b/cmd/agent_test.go @@ -1301,9 +1301,10 @@ func TestAgentRunEvidenceIncludesPluginRunSummaryAndErrorArtifacts(t *testing.T) t.Fatalf("expected readable failure reason, got %q", evidence.Status.Reason) } expectedLabels := map[string]string{ - "_agent": clientID, - "tool": "ccf", - "type": "operations", + "_agent": clientID, + agentConfigHashLabel: agentConfigurationHash(agentRunner.getConfig()), + "tool": "ccf", + "type": "operations", } for key, expected := range expectedLabels { if evidence.Labels[key] != expected { @@ -1656,8 +1657,11 @@ func TestSendAgentRunEvidenceAllowsNoPlugins(t *testing.T) { if labels["_agent"] != "ccf" || labels["tool"] != "ccf" || labels["type"] != "operations" { t.Fatalf("unexpected foundational labels: %#v", labels) } - if len(labels) != 3 { - t.Fatalf("expected only three foundational labels, got %#v", labels) + if labels[agentConfigHashLabel] != agentConfigurationHash(agentRunner.getConfig()) { + t.Fatalf("expected agent config hash label, got %#v", labels) + } + if len(labels) != 4 { + t.Fatalf("expected only four foundational labels, got %#v", labels) } if _, ok := submitted["back-matter"]; ok { t.Fatalf("expected no backmatter for passing no-plugin evidence, got %#v", submitted["back-matter"]) @@ -1720,6 +1724,192 @@ func TestAgentIdentityLabelFallsBackToKubernetesPodName(t *testing.T) { } } +func TestAgentConfigurationHashUsesRuntimeConfigOnly(t *testing.T) { + base := newRuntimeHashTestConfig() + baseHash := agentConfigurationHash(base) + if len(baseHash) != 64 { + t.Fatalf("expected sha256 hex hash, got %q", baseHash) + } + + reordered := newRuntimeHashTestConfigWithReorderedPlugins() + reordered.ApiConfig = &apiConfig{ + Url: "http://different.example.test", + Auth: &apiAuthConfig{ + ClientID: "123e4567-e89b-12d3-a456-426614174000", + ClientSecret: "different-secret", + }, + } + reordered.Verbosity = 3 + reordered.Daemon = !base.Daemon + if got := agentConfigurationHash(reordered); got != baseHash { + t.Fatalf("expected reordered plugins and excluded fields to keep hash stable, got %q want %q", got, baseHash) + } + + tests := []struct { + name string + mutate func(*agentConfig) + }{ + { + name: "plugin source", + mutate: func(config *agentConfig) { + config.Plugins["plugin-a"].Source = "ghcr.io/example/plugin-a:v2" + }, + }, + { + name: "plugin schedule", + mutate: func(config *agentConfig) { + schedule := "0 * * * *" + config.Plugins["plugin-a"].Schedule = &schedule + }, + }, + { + name: "plugin policy", + mutate: func(config *agentConfig) { + config.Plugins["plugin-a"].Policies = append(config.Plugins["plugin-a"].Policies, "policy-c") + }, + }, + { + name: "plugin config", + mutate: func(config *agentConfig) { + config.Plugins["plugin-a"].Config["region"] = "eu-west-1" + }, + }, + { + name: "plugin label", + mutate: func(config *agentConfig) { + config.Plugins["plugin-a"].Labels["environment"] = "prod" + }, + }, + { + name: "protocol version", + mutate: func(config *agentConfig) { + config.Plugins["plugin-a"].ProtocolVersion = RunnerV2ProtocolVersion + }, + }, + { + name: "agent evidence interval", + mutate: func(config *agentConfig) { + config.AgentEvidence.Interval = "2h" + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := newRuntimeHashTestConfig() + tt.mutate(config) + if got := agentConfigurationHash(config); got == baseHash { + t.Fatalf("expected %s change to alter hash %q", tt.name, got) + } + }) + } +} + +func TestAgentFoundationalLabelsIncludeAgentConfigurationHash(t *testing.T) { + config := newRuntimeHashTestConfig() + + labels := agentFoundationalLabels(config) + + if labels[agentConfigHashLabel] != agentConfigurationHash(config) { + t.Fatalf("expected foundational labels to include config hash, got %#v", labels) + } +} + +func TestAgentRunEvidenceUUIDUsesAgentConfigurationHash(t *testing.T) { + now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) + firstRunner := NewAgentRunner() + firstRunner.UpdateConfig(newRuntimeHashTestConfig()) + firstEvidence, err := firstRunner.buildAgentRunEvidence(now) + if err != nil { + t.Fatalf("build first agent run evidence: %v", err) + } + + secondConfig := newRuntimeHashTestConfig() + secondConfig.Plugins["plugin-a"].Source = "ghcr.io/example/plugin-a:v2" + secondRunner := NewAgentRunner() + secondRunner.UpdateConfig(secondConfig) + secondEvidence, err := secondRunner.buildAgentRunEvidence(now) + if err != nil { + t.Fatalf("build second agent run evidence: %v", err) + } + + if firstEvidence.Labels[agentConfigHashLabel] == secondEvidence.Labels[agentConfigHashLabel] { + t.Fatalf("expected different config hash labels, got %q", firstEvidence.Labels[agentConfigHashLabel]) + } + if firstEvidence.UUID == secondEvidence.UUID { + t.Fatalf("expected config hash change to alter agent evidence UUID %s", firstEvidence.UUID) + } +} + +func TestPluginEvidenceLabelsIncludeAgentConfigurationHash(t *testing.T) { + config := newRuntimeHashTestConfig() + + labels := pluginEvidenceLabels(config, "plugin-a", config.Plugins["plugin-a"]) + + if labels[agentConfigHashLabel] != agentConfigurationHash(config) { + t.Fatalf("expected plugin labels to include config hash, got %#v", labels) + } + if labels["_plugin"] != "plugin-a" { + t.Fatalf("expected plugin label, got %#v", labels) + } + if labels["team"] != "security" { + t.Fatalf("expected configured plugin labels to be preserved, got %#v", labels) + } +} + +func TestPluginEvidenceSubmissionIncludesAgentConfigurationHash(t *testing.T) { + config := newRuntimeHashTestConfig() + config.ApiConfig.Auth = nil + var submittedLabels map[string]string + client := newTestHTTPClient(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/api/evidence" { + t.Fatalf("unexpected path %q", r.URL.Path) + return nil, nil + } + var submitted struct { + Labels map[string]string `json:"labels"` + } + if err := json.NewDecoder(r.Body).Decode(&submitted); err != nil { + t.Fatalf("decode evidence request: %v", err) + } + submittedLabels = submitted.Labels + return jsonResponse(http.StatusCreated, ""), nil + }) + + agentRunner := NewAgentRunner() + agentRunner.httpClient = client + agentRunner.UpdateConfig(config) + apiHelper := runner.NewApiHelper( + hclog.NewNullLogger(), + agentRunner.getAPIClient(), + pluginEvidenceLabels(config, "plugin-a", config.Plugins["plugin-a"]), + "plugin-a", + ) + + now := time.Now().UTC() + if err := apiHelper.CreateEvidence(context.Background(), []*proto.Evidence{ + { + UUID: uuid.NewString(), + Title: "Evidence", + Start: timestamppb.New(now.Add(-time.Minute)), + End: timestamppb.New(now), + Status: &proto.EvidenceStatus{ + Reason: "pass", + State: proto.EvidenceStatusState_EVIDENCE_STATUS_STATE_SATISFIED, + }, + }, + }); err != nil { + t.Fatalf("create evidence: %v", err) + } + + if submittedLabels[agentConfigHashLabel] != agentConfigurationHash(config) { + t.Fatalf("expected submitted plugin evidence to include config hash, got %#v", submittedLabels) + } + if submittedLabels["_plugin"] != "plugin-a" || submittedLabels["team"] != "security" { + t.Fatalf("expected submitted plugin evidence to include plugin labels, got %#v", submittedLabels) + } +} + func TestAgentEvidenceConfigDefaultsAndValidation(t *testing.T) { config := &agentConfig{ ApiConfig: &apiConfig{Url: "http://localhost:8080"}, @@ -1761,6 +1951,57 @@ func newTestAgentConfig(baseURL string, auth *apiAuthConfig) *agentConfig { } } +func newRuntimeHashTestConfig() *agentConfig { + schedule := "*/5 * * * *" + emitOnRunCompletion := true + enabled := true + return &agentConfig{ + Daemon: true, + Verbosity: 1, + ApiConfig: &apiConfig{ + Url: "http://example.test", + Auth: &apiAuthConfig{ + ClientID: "00000000-0000-0000-0000-000000000001", + ClientSecret: "client-secret", + }, + }, + AgentEvidence: &agentEvidenceConfig{ + Enabled: &enabled, + EmitOnRunCompletion: &emitOnRunCompletion, + Interval: "1h", + }, + Plugins: map[string]*agentPlugin{ + "plugin-a": { + ProtocolVersion: DefaultProtocolVersion, + Schedule: &schedule, + Source: "ghcr.io/example/plugin-a:v1", + Policies: []agentPolicy{"policy-a", "policy-b"}, + Config: agentPluginConfig{ + "region": "us-east-1", + "token": "secret-token", + }, + Labels: map[string]string{ + "team": "security", + }, + }, + "plugin-b": { + ProtocolVersion: DefaultProtocolVersion, + Source: "ghcr.io/example/plugin-b:v1", + }, + }, + } +} + +func newRuntimeHashTestConfigWithReorderedPlugins() *agentConfig { + config := newRuntimeHashTestConfig() + plugins := config.Plugins + config.Plugins = map[string]*agentPlugin{ + "plugin-b": plugins["plugin-b"], + "plugin-a": plugins["plugin-a"], + } + return config +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/docs/configuration.md b/docs/configuration.md index 6bd7481..4324cd3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,8 +40,10 @@ The `plugin_identifier` is a unique identifier for the plugin, and is used to id name this whatever you like but it must be unique. The `labels` should uniquely identify this agent instance. The agent also sets the `_agent` label on plugin evidence -using `api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally `ccf`. Because -evidence UUIDs are seeded from labels, changing that identity changes the evidence stream for plugin evidence. +using `api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally `ccf`. The agent +also sets `_agent_config_hash`, a deterministic hash of the runtime plugin and agent evidence configuration. Because +evidence UUIDs are seeded from labels, changing either the agent identity or runtime configuration changes the evidence +stream for plugin evidence. The `plugin_source` is the path to the plugin binary that the agent will run. This can be a relative or absolute path or even a URL to a remote plugin. @@ -62,9 +64,11 @@ is `satisfied`. A plugin remains in the `Plugins with errors` summary until it f Plugins that have never run are listed as pending. Failed plugin errors are attached as back-matter resources and linked from the evidence so they can be downloaded. -Agent evidence uses only these labels: `_agent`, `tool`, and `type`. The `_agent` label uses `api.auth.client_id` when -available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally defaults to `ccf`. The `tool` label is `ccf`; -the `type` label is `operations`. +Agent evidence uses these labels: `_agent`, `_agent_config_hash`, `tool`, and `type`. The `_agent` label uses +`api.auth.client_id` when available, then `KUBERNETES_POD_NAME` or `KUBERNETES_POD`, and finally defaults to `ccf`. The +`_agent_config_hash` label is a SHA-256 hash of plugin names, sources, protocol versions, schedules, policies, plugin +config, plugin labels, and `agent_evidence` settings. It does not include API URL, API auth, or verbosity. The `tool` +label is `ccf`; the `type` label is `operations`. If no plugins are configured, ccf-agent still emits passing agent evidence on the configured interval when running in daemon mode. In non-daemon mode, ccf-agent can emit agent evidence only once per invocation.