diff --git a/AGENTS.md b/AGENTS.md index 4752fc9..e92182f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,8 @@ change here must keep working with them. and `_policy_data_digest`. - **The agent never computes artifact digests.** It passes each evaluation's policy directory, input and policy data through to the API, which canonicalises and hashes them. +- **Evidence identity.** `policy-manager`'s `newEvidence` seed is every evidence stream's UUID, and plugins in the + field compute it. Never change it. The golden test in `policy-manager/evidence_seed_test.go` pins the UUIDs. - **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/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 +} diff --git a/policy-manager/evidence_seed_test.go b/policy-manager/evidence_seed_test.go new file mode 100644 index 0000000..625c4bc --- /dev/null +++ b/policy-manager/evidence_seed_test.go @@ -0,0 +1,47 @@ +package policy_manager + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestEvidenceSeedIsUnchanged pins the evidence UUIDs plugins in the field produce for +// several label and path combinations. Every evidence stream depends on them: they must +// never change. +func TestEvidenceSeedIsUnchanged(t *testing.T) { + const ( + // vendorPath is the policy path an OCI bundle is passed as: relative to the agent's + // working directory, with the repository and tag. + vendorPath = ".compliance-framework/policies/compliance-framework/plugin-local-ssh-policies/v0.2.0/policies" + // localPath is a local policy source, passed as configured. + localPath = "/etc/ccf/policies/ssh" + ) + sshLabels := func(policyPath string) map[string]string { + return map[string]string{"type": "ssh", "hostname": "web-1", "_policy_path": policyPath} + } + cases := []struct { + name string + labels map[string]string + file, pkg string + want string + }{ + {"relative OCI path", sshLabels(vendorPath), vendorPath + "/ssh_deny_password_auth.rego", "data.compliance_framework.ssh_deny_password_auth", "cede5222-a458-4465-8134-c3751575cdd9"}, + {"absolute path, nested file", sshLabels(localPath), localPath + "/banner/banner.rego", "data.compliance_framework.banner", "0c0ee58a-50ca-45f1-98d3-0f9602f24101"}, + {"no _policy_path label", map[string]string{"_plugin": "test-plugin"}, "test.rego", "data.compliance_framework.no_policy_path", "271009cd-7758-432e-8869-84fa710b0f5a"}, + {"no labels", nil, "policies/a.rego", "data.compliance_framework.a", "9eb28e96-4f5a-416a-9623-62430b8e089f"}, + {"trailing slash", map[string]string{"type": "k8s", "_policy_path": "policies/", "cluster": "prod"}, "policies/a.rego", "data.compliance_framework.a", "98fc06e7-b1a4-4d83-a272-df4fdd75d06c"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := &PolicyProcessor{labels: tc.labels} + e, err := p.newEvidence(Result{ + Policy: Policy{File: tc.file, Package: Package(tc.pkg)}, + EvalOutput: &EvalOutput{Title: Pointer("t")}, + }, nil) + require.NoError(t, err) + assert.Equal(t, tc.want, e.UUID) + }) + } +}