diff --git a/pkg/agentconfig/classify.go b/pkg/agentconfig/classify.go new file mode 100644 index 00000000..77db9adf --- /dev/null +++ b/pkg/agentconfig/classify.go @@ -0,0 +1,344 @@ +package agentconfig + +import ( + "cmp" + "encoding/json" + "fmt" + "slices" + "strings" +) + +// Safety is the class of one effective-config change (§3.6). +type Safety string + +const ( + Safe Safety = "safe" + Unsafe Safety = "unsafe" + Forbidden Safety = "forbidden" +) + +func (s Safety) rank() int { + switch s { + case Forbidden: + return 2 + case Unsafe: + return 1 + default: + return 0 + } +} + +// Change reason codes (stable; translated by the UI). +const ( + ChangeReasonLockedKey = "locked-key" + ChangeReasonLogging = "logging" + ChangeReasonDataOnly = "data-only" + ChangeReasonReducesScope = "reduces-scope" + ChangeReasonAlreadyUsed = "already-used" + ChangeReasonTrustedSource = "trusted-source" + ChangeReasonUntrustedSource = "untrusted-source" + ChangeReasonLocalSourceNotAllowed = "local-source-not-allowed" + ChangeReasonNewLocalSource = "new-local-source" + ChangeReasonOverridableConfigFlag = "overridable-config-flag" + ChangeReasonConfigNotOverridable = "config-not-overridable" + ChangeReasonNewEnvReference = "new-env-reference" + ChangeReasonForbiddenEnvReference = "forbidden-env-reference" + ChangeReasonReenablePlugin = "reenables-plugin" +) + +// WillApply reasons besides ReasonUnsafeChanges / ReasonForbiddenChanges. +const ( + WillApplyReasonModeOff = "mode-off" + WillApplyReasonModeReport = "mode-report" +) + +// Change is one classified difference between the base and the effective config. +type Change struct { + Path string `json:"path"` // RFC 6901 pointer + Safety Safety `json:"safety"` + Reason string `json:"reason"` + Value string `json:"value,omitempty"` // the source / env name that triggered the class +} + +// Classify diffs Merge(base, overlay) against base and classifies every changed path. rc +// must be normalized. A locked key in the raw overlay is Forbidden even though Merge strips +// it. An overlay null is a deletion and is classified as one; an omitted key produces no +// Change. The result is sorted by Path, then Value. +// +// Re-enabling a plugin the base disables is Unsafe (reenables-plugin) unless its source is +// trusted (trusted-source). Its other parts are classified as if the plugin were new, since a +// disabled plugin's sources are not already used: a local plugin source goes through the +// source rules (Forbidden unless apply_all with allow_local_sources, as for a new plugin), +// every policy entry it keeps goes through the source rules, and every ${env:} reference in +// its config is a new reference. By design, apply_safe accepts the rest of a plugin's data +// without a host opt-in: schedule, labels, policy_data, policy_behavior, protocol_version and +// disabling a plugin are Safe (data-only), and removing a plugin or policy entries is Safe +// (reduces-scope). So a remote editor can change the policy inputs that decide pass/fail, +// or stop a plugin from running, on an apply_safe host. +func Classify(base Config, overlay json.RawMessage, rc RemoteConfig) ([]Change, error) { + raw, err := decodeAny(overlay) + if err != nil { + return nil, fmt.Errorf("classify: decode overlay: %w", err) + } + var changes []Change + if obj, ok := raw.(map[string]any); ok { + for _, k := range LockedKeys { + if _, present := obj[k]; present { + changes = append(changes, Change{Path: Pointer(k), Safety: Forbidden, Reason: ChangeReasonLockedKey}) + } + } + } else if raw != nil { + return nil, fmt.Errorf("classify: overlay must be a JSON object") + } + + eff, err := Merge(base, overlay) + if err != nil { + return nil, fmt.Errorf("classify: %w", err) + } + cl := classifier{rc: rc, used: usedSources(base)} + changes = append(changes, cl.classify(base, eff)...) + slices.SortFunc(changes, func(a, b Change) int { + return cmp.Or(strings.Compare(a.Path, b.Path), strings.Compare(a.Value, b.Value), strings.Compare(a.Reason, b.Reason)) + }) + return slices.Compact(changes), nil +} + +// WillApply decides whether an agent in rc.Mode applies a revision with these changes: +// off -> (false, "mode-off"); report -> (false, "mode-report"); any Forbidden -> +// (false, "forbidden-changes") in every apply mode (R23); apply_safe with any Unsafe -> +// (false, "unsafe-changes"); otherwise (true, ""). +func WillApply(rc RemoteConfig, changes []Change) (bool, string) { + switch rc.Mode { + case ModeApplySafe, ModeApplyAll: + case ModeReport: + return false, WillApplyReasonModeReport + default: + return false, WillApplyReasonModeOff + } + worst := Safe + for _, c := range changes { + if c.Safety.rank() > worst.rank() { + worst = c.Safety + } + } + switch { + case worst == Forbidden: + return false, ReasonForbiddenChanges + case worst == Unsafe && rc.Mode == ModeApplySafe: + return false, ReasonUnsafeChanges + } + return true, "" +} + +type classifier struct { + rc RemoteConfig + used map[string]bool +} + +// usedSources is every plugin source and policy entry of the base's enabled plugins. A +// disabled plugin's sources do not count: the host chose not to run them, so pointing +// another plugin at one is a new source, not an already-used one. +func usedSources(base Config) map[string]bool { + used := map[string]bool{} + for _, p := range base.Plugins { + if p == nil || !p.IsEnabled() { + continue + } + if p.Source != "" { + used[p.Source] = true + } + for _, e := range p.Policies { + used[e] = true + } + } + return used +} + +func (cl classifier) sourceClass(path, s string) Change { + switch { + case cl.used[s]: + return Change{Path: path, Safety: Safe, Reason: ChangeReasonAlreadyUsed, Value: s} + case KindOf(s) == SourceKindLocal: + if cl.rc.Mode == ModeApplyAll && cl.rc.AllowLocalSources { + return Change{Path: path, Safety: Unsafe, Reason: ChangeReasonNewLocalSource, Value: s} + } + return Change{Path: path, Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: s} + case MatchTrustedSource(cl.rc, s): + return Change{Path: path, Safety: Safe, Reason: ChangeReasonTrustedSource, Value: s} + default: + return Change{Path: path, Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: s} + } +} + +func (cl classifier) classify(base, eff Config) []Change { + var out []Change + if base.Verbosity != eff.Verbosity { + out = append(out, Change{Path: "/verbosity", Safety: Safe, Reason: ChangeReasonLogging}) + } + for _, p := range changedLeaves("/agent_evidence", base.AgentEvidence, eff.AgentEvidence) { + out = append(out, Change{Path: p, Safety: Safe, Reason: ChangeReasonLogging}) + } + + for _, name := range unionMapKeys(base.Plugins, eff.Plugins) { + ptr := Pointer("plugins", name) + bp, inBase := base.Plugins[name] + ep, inEff := eff.Plugins[name] + if inBase && bp != nil && (!inEff || ep == nil) { + out = append(out, Change{Path: ptr, Safety: Safe, Reason: ChangeReasonReducesScope}) + continue + } + if ep == nil { + continue + } + if bp == nil { + bp = &Plugin{} // a new plugin is the class of its parts + } + out = append(out, cl.classifyPlugin(ptr, name, bp, ep)...) + } + return out +} + +func (cl classifier) classifyPlugin(ptr, name string, bp, ep *Plugin) []Change { + var out []Change + dataOnly := func(field string, a, b any) { + if !jsonValueEqual(a, b) { + out = append(out, Change{Path: ptr + "/" + field, Safety: Safe, Reason: ChangeReasonDataOnly}) + } + } + dataOnly("schedule", bp.Schedule, ep.Schedule) + dataOnly("labels", nilIfEmptyMap(bp.Labels), nilIfEmptyMap(ep.Labels)) + dataOnly("policy_behavior", nilIfEmptyMap(bp.PolicyBehavior), nilIfEmptyMap(ep.PolicyBehavior)) + dataOnly("protocol_version", bp.ProtocolVersion, ep.ProtocolVersion) + reenabled := !bp.IsEnabled() && ep.IsEnabled() + if reenabled { + // Re-enabling a plugin the host disabled runs it again: Unsafe unless trusted. + if MatchTrustedSource(cl.rc, ep.Source) { + out = append(out, Change{Path: ptr + "/enabled", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: ep.Source}) + } else { + out = append(out, Change{Path: ptr + "/enabled", Safety: Unsafe, Reason: ChangeReasonReenablePlugin, Value: ep.Source}) + } + // A kept local source is not already used either: classify it like a new plugin's, so + // allow_local_sources cannot be bypassed by re-enabling. A changed source is classified + // below. + if bp.Source == ep.Source && KindOf(ep.Source) == SourceKindLocal { + out = append(out, cl.sourceClass(ptr+"/source", ep.Source)) + } + // Its policies are not already used (usedSources skips disabled plugins), so the + // entries it keeps are classified like new ones; added entries are classified below. + for _, e := range ep.Policies { + if slices.Contains(bp.Policies, e) { + out = append(out, cl.sourceClass(ptr+"/policies", e)) + } + } + } else { + dataOnly("enabled", bp.IsEnabled(), ep.IsEnabled()) + } + dataOnly("policy_data", nilIfEmptyMap(bp.PolicyData), nilIfEmptyMap(ep.PolicyData)) + + if bp.Source != ep.Source { + out = append(out, cl.sourceClass(ptr+"/source", ep.Source)) + } + + if !slices.Equal(bp.Policies, ep.Policies) { + polPtr := ptr + "/policies" + added := false + for _, e := range ep.Policies { + if slices.Contains(bp.Policies, e) { + continue + } + added = true + out = append(out, cl.sourceClass(polPtr, e)) + } + if !added { + out = append(out, Change{Path: polPtr, Safety: Safe, Reason: ChangeReasonReducesScope}) + } + } + + for _, key := range unionMapKeys(bp.Config, ep.Config) { + bv, inBase := bp.Config[key] + ev, inEff := ep.Config[key] + unchanged := inBase == inEff && bv == ev + if unchanged && !reenabled { + continue + } + kptr := ptr + "/config/" + EscapePointerToken(key) + // Env rule (R24): a variable not referenced by the base value at the same pointer. A + // re-enabled plugin's references are not in use, so all of them count as new. + baseRefs := EnvRefs(bv) + if reenabled { + baseRefs = nil + } + var envChanges []Change + for _, n := range EnvRefs(ev) { + if slices.Contains(baseRefs, n) { + continue + } + if IsForbiddenEnvName(n) { + envChanges = append(envChanges, Change{Path: kptr, Safety: Forbidden, Reason: ChangeReasonForbiddenEnvReference, Value: n}) + } else { + envChanges = append(envChanges, Change{Path: kptr, Safety: Unsafe, Reason: ChangeReasonNewEnvReference, Value: n}) + } + } + if len(envChanges) > 0 { + out = append(out, envChanges...) + continue + } + if unchanged { + continue // re-enabled: only the env references of values the overlay keeps are checked + } + if MatchOverridableConfigFlag(cl.rc, name, key) { + out = append(out, Change{Path: kptr, Safety: Safe, Reason: ChangeReasonOverridableConfigFlag}) + } else { + out = append(out, Change{Path: kptr, Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}) + } + } + return out +} + +// changedLeaves returns the pointers (under prefix) of the leaves that differ between two +// JSON-encodable values; a nil side counts as an empty object. +func changedLeaves(prefix string, a, b any) []string { + da := toDecoded(a) + db := toDecoded(b) + var paths []string + leafDiffPaths(prefix, da, db, da != nil, db != nil, &paths) + return paths +} + +func toDecoded(v any) any { + raw, err := json.Marshal(v) + if err != nil { + return nil + } + d, err := decodeAny(raw) + if err != nil { + return nil + } + return d +} + +func jsonValueEqual(a, b any) bool { + return jsonEqual(toDecoded(a), toDecoded(b)) +} + +// nilIfEmptyMap treats an empty map like an absent one (omitempty semantics). +func nilIfEmptyMap[K comparable, V any](m map[K]V) any { + if len(m) == 0 { + return nil + } + return m +} + +func unionMapKeys[V any](a, b map[string]V) []string { + keys := make([]string, 0, len(a)+len(b)) + for k := range a { + keys = append(keys, k) + } + for k := range b { + if _, ok := a[k]; !ok { + keys = append(keys, k) + } + } + slices.Sort(keys) + return keys +} diff --git a/pkg/agentconfig/classify_reenable_local_regression_test.go b/pkg/agentconfig/classify_reenable_local_regression_test.go new file mode 100644 index 00000000..6948fa3b --- /dev/null +++ b/pkg/agentconfig/classify_reenable_local_regression_test.go @@ -0,0 +1,47 @@ +package agentconfig + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Regression (review #471, fp 2db275ed2d26): re-enabling a plugin the base disables must +// classify its local source like a new plugin's, so allow_local_sources cannot be bypassed. +func TestClassifyReenabledPluginLocalSource(t *testing.T) { + disabled := false + base := Config{Plugins: map[string]*Plugin{ + "x": {Enabled: &disabled, Source: "./bin/local-plugin"}, + }} + overlay := json.RawMessage(`{"plugins":{"x":{"enabled":true}}}`) + + t.Run("apply_all without allow_local_sources is forbidden", func(t *testing.T) { + rc := RemoteConfig{Mode: ModeApplyAll}.Normalize(true) + changes, err := Classify(base, overlay, rc) + require.NoError(t, err) + assert.Contains(t, changes, Change{Path: "/plugins/x/source", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: "./bin/local-plugin"}) + apply, reason := WillApply(rc, changes) + assert.False(t, apply) + assert.Equal(t, ReasonForbiddenChanges, reason) + }) + + t.Run("apply_all with allow_local_sources is unsafe and applies", func(t *testing.T) { + rc := RemoteConfig{Mode: ModeApplyAll, AllowLocalSources: true}.Normalize(true) + changes, err := Classify(base, overlay, rc) + require.NoError(t, err) + assert.Contains(t, changes, Change{Path: "/plugins/x/source", Safety: Unsafe, Reason: ChangeReasonNewLocalSource, Value: "./bin/local-plugin"}) + apply, _ := WillApply(rc, changes) + assert.True(t, apply) + }) + + t.Run("apply_safe is refused", func(t *testing.T) { + rc := RemoteConfig{Mode: ModeApplySafe}.Normalize(true) + changes, err := Classify(base, overlay, rc) + require.NoError(t, err) + apply, reason := WillApply(rc, changes) + assert.False(t, apply) + assert.Equal(t, ReasonForbiddenChanges, reason) + }) +} diff --git a/pkg/agentconfig/classify_test.go b/pkg/agentconfig/classify_test.go new file mode 100644 index 00000000..596996b9 --- /dev/null +++ b/pkg/agentconfig/classify_test.go @@ -0,0 +1,334 @@ +package agentconfig + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + srcSSH = "ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0" + srcSSHv2 = "ghcr.io/compliance-framework/plugin-local-ssh:v2.0.0" + srcPolicies = "ghcr.io/compliance-framework/plugin-local-ssh-policies:v1.0.0" + srcCommon = "ghcr.io/compliance-framework/common-policies:v1" + srcDisabled = "ghcr.io/other/plugin-disabled:v1" + srcUntrusted = "ghcr.io/evil/plugin:v1" + srcLocalUsed = "./local/policies" + srcLocalNew = "./bin/plugin" +) + +func classifyBase() Config { + return Config{ + Daemon: true, + Verbosity: 0, + API: &APIConfig{ + URL: "https://api.example.com", + Auth: &APIAuth{ClientID: "0b3c1b8a-7c8e-4d53-9a52-9a3c1d1f2e10", ClientSecret: "s3cret"}, + }, + Plugins: map[string]*Plugin{ + "local-ssh": { + Source: srcSSH, + Schedule: strPtr("*/5 * * * *"), + ProtocolVersion: 2, + Policies: []string{srcPolicies, srcCommon}, + Config: map[string]string{"host": "localhost", "port": "22", "password": "${env:SSH_PASSWORD}"}, + Labels: map[string]string{"env": "dev"}, + PolicyData: map[string]any{"threshold": json.Number("5")}, + }, + "disabled": { + Enabled: boolPtr(false), + Source: srcDisabled, + Policies: []string{srcLocalUsed}, + }, + }, + } +} + +func testRC(mode string, mutate ...func(*RemoteConfig)) RemoteConfig { + rc := RemoteConfig{ + Mode: mode, + TrustedSources: []string{"ghcr.io/compliance-framework/*"}, + OverridableConfigFlags: []string{"local-ssh:port"}, + } + for _, m := range mutate { + m(&rc) + } + return rc.Normalize(true) +} + +func allowLocal(rc *RemoteConfig) { rc.AllowLocalSources = true } +func overridable(flags ...string) func(*RemoteConfig) { + return func(rc *RemoteConfig) { rc.OverridableConfigFlags = flags } +} + +func TestClassify(t *testing.T) { + safe := testRC(ModeApplySafe) + tests := []struct { + name string + overlay string + rc RemoteConfig + want []Change + }{ + {name: "empty overlay", overlay: `{}`, rc: safe, want: nil}, + {name: "null overlay", overlay: `null`, rc: safe, want: nil}, + { + name: "unchanged values produce no change", + overlay: `{"verbosity":0,"plugins":{"local-ssh":{"source":"` + srcSSH + `","config":{"port":"22"},"enabled":true,"policies":["` + srcPolicies + `","` + srcCommon + `"]}}}`, + rc: safe, + want: nil, + }, + // Locked keys. + {name: "locked api", overlay: `{"api":{"url":"https://evil"}}`, rc: safe, want: []Change{{Path: "/api", Safety: Forbidden, Reason: ChangeReasonLockedKey}}}, + {name: "locked daemon null", overlay: `{"daemon":null}`, rc: safe, want: []Change{{Path: "/daemon", Safety: Forbidden, Reason: ChangeReasonLockedKey}}}, + { + name: "all locked keys", + overlay: `{"remote_config":{"mode":"apply_all"},"daemon":false,"api":null}`, + rc: safe, + want: []Change{ + {Path: "/api", Safety: Forbidden, Reason: ChangeReasonLockedKey}, + {Path: "/daemon", Safety: Forbidden, Reason: ChangeReasonLockedKey}, + {Path: "/remote_config", Safety: Forbidden, Reason: ChangeReasonLockedKey}, + }, + }, + // Logging. + {name: "verbosity", overlay: `{"verbosity":2}`, rc: safe, want: []Change{{Path: "/verbosity", Safety: Safe, Reason: ChangeReasonLogging}}}, + { + name: "agent_evidence", + overlay: `{"agent_evidence":{"enabled":false,"interval":"1m"}}`, + rc: safe, + want: []Change{ + {Path: "/agent_evidence/enabled", Safety: Safe, Reason: ChangeReasonLogging}, + {Path: "/agent_evidence/interval", Safety: Safe, Reason: ChangeReasonLogging}, + }, + }, + // Plugin removal. + {name: "plugin null reduces scope", overlay: `{"plugins":{"local-ssh":null}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh", Safety: Safe, Reason: ChangeReasonReducesScope}}}, + {name: "unknown plugin null is a no-op", overlay: `{"plugins":{"nope":null}}`, rc: safe, want: nil}, + // Data-only. + {name: "schedule", overlay: `{"plugins":{"local-ssh":{"schedule":"@hourly"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/schedule", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "schedule null", overlay: `{"plugins":{"local-ssh":{"schedule":null}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/schedule", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "labels", overlay: `{"plugins":{"local-ssh":{"labels":{"env":"prod"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/labels", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "labels removed", overlay: `{"plugins":{"local-ssh":{"labels":null}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/labels", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "policy_behavior", overlay: `{"plugins":{"local-ssh":{"policy_behavior":{"deny":["warn"]}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policy_behavior", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "protocol_version", overlay: `{"plugins":{"local-ssh":{"protocol_version":1}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/protocol_version", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "protocol_version null", overlay: `{"plugins":{"local-ssh":{"protocol_version":null}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/protocol_version", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "enabled false", overlay: `{"plugins":{"local-ssh":{"enabled":false}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/enabled", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + {name: "enabled true on a nil-enabled plugin is no change", overlay: `{"plugins":{"local-ssh":{"enabled":true}}}`, rc: safe, want: nil}, + // Re-enabling: the disabled plugin keeps the local policy ./local/policies, which no + // enabled plugin uses, so it is classified like a new entry. + {name: "re-enabling an untrusted plugin", overlay: `{"plugins":{"disabled":{"enabled":true}}}`, rc: safe, want: []Change{ + {Path: "/plugins/disabled/enabled", Safety: Unsafe, Reason: ChangeReasonReenablePlugin, Value: srcDisabled}, + {Path: "/plugins/disabled/policies", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: srcLocalUsed}, + }}, + {name: "re-enabling a trusted plugin", overlay: `{"plugins":{"disabled":{"enabled":null}}}`, rc: testRC(ModeApplySafe, func(rc *RemoteConfig) { rc.TrustedSources = []string{"ghcr.io/other/*"} }), want: []Change{ + {Path: "/plugins/disabled/enabled", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: srcDisabled}, + {Path: "/plugins/disabled/policies", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: srcLocalUsed}, + }}, + {name: "re-enabling a trusted plugin without its local policy", overlay: `{"plugins":{"disabled":{"enabled":true,"policies":null}}}`, rc: testRC(ModeApplySafe, func(rc *RemoteConfig) { rc.TrustedSources = []string{"ghcr.io/other/*"} }), want: []Change{ + {Path: "/plugins/disabled/enabled", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: srcDisabled}, + {Path: "/plugins/disabled/policies", Safety: Safe, Reason: ChangeReasonReducesScope}, + }}, + {name: "re-enabling with a new source", overlay: `{"plugins":{"disabled":{"enabled":true,"source":"` + srcSSHv2 + `"}}}`, rc: safe, want: []Change{ + {Path: "/plugins/disabled/enabled", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: srcSSHv2}, + {Path: "/plugins/disabled/policies", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: srcLocalUsed}, + {Path: "/plugins/disabled/source", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: srcSSHv2}, + }}, + {name: "re-enabling a trusted plugin with an untrusted policy", overlay: `{"plugins":{"disabled":{"enabled":true,"policies":["` + srcUntrusted + `"]}}}`, rc: testRC(ModeApplySafe, func(rc *RemoteConfig) { rc.TrustedSources = []string{"ghcr.io/other/*"} }), want: []Change{ + {Path: "/plugins/disabled/enabled", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: srcDisabled}, + {Path: "/plugins/disabled/policies", Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: srcUntrusted}, + }}, + {name: "policy_data", overlay: `{"plugins":{"local-ssh":{"policy_data":{"threshold":6}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policy_data", Safety: Safe, Reason: ChangeReasonDataOnly}}}, + { + name: "policy_data is always safe, even with secret-like keys and env-like strings", + overlay: `{"plugins":{"local-ssh":{"policy_data":{"password":"x","ref":"${env:CCF_API_AUTH_CLIENT_SECRET}"}}}}`, + rc: testRC(ModeApplySafe, overridable()), + want: []Change{{Path: "/plugins/local-ssh/policy_data", Safety: Safe, Reason: ChangeReasonDataOnly}}, + }, + // Source. + {name: "source trusted", overlay: `{"plugins":{"local-ssh":{"source":"` + srcSSHv2 + `"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/source", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: srcSSHv2}}}, + {name: "source untrusted", overlay: `{"plugins":{"local-ssh":{"source":"` + srcUntrusted + `"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/source", Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: srcUntrusted}}}, + {name: "source trust does not cross /", overlay: `{"plugins":{"local-ssh":{"source":"ghcr.io/compliance-framework/sub/p:v1"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/source", Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: "ghcr.io/compliance-framework/sub/p:v1"}}}, + // A disabled plugin's sources are not already used: the host chose not to run them. + {name: "source used only by a disabled plugin", overlay: `{"plugins":{"local-ssh":{"source":"` + srcDisabled + `"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/source", Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: srcDisabled}}}, + {name: "new plugin with a disabled plugin's source", overlay: `{"plugins":{"bad2":{"source":"` + srcDisabled + `"}}}`, rc: safe, want: []Change{{Path: "/plugins/bad2/source", Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: srcDisabled}}}, + {name: "source used only as a disabled plugin's policy", overlay: `{"plugins":{"local-ssh":{"source":"` + srcLocalUsed + `"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/source", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: srcLocalUsed}}}, + {name: "source already used as a policy", overlay: `{"plugins":{"local-ssh":{"source":"` + srcPolicies + `"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/source", Safety: Safe, Reason: ChangeReasonAlreadyUsed, Value: srcPolicies}}}, + {name: "source local in apply_safe", overlay: `{"plugins":{"local-ssh":{"source":"` + srcLocalNew + `"}}}`, rc: testRC(ModeApplySafe, allowLocal), want: []Change{{Path: "/plugins/local-ssh/source", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: srcLocalNew}}}, + {name: "source local in apply_all without allow_local_sources", overlay: `{"plugins":{"local-ssh":{"source":"` + srcLocalNew + `"}}}`, rc: testRC(ModeApplyAll), want: []Change{{Path: "/plugins/local-ssh/source", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: srcLocalNew}}}, + {name: "source local in apply_all with allow_local_sources", overlay: `{"plugins":{"local-ssh":{"source":"` + srcLocalNew + `"}}}`, rc: testRC(ModeApplyAll, allowLocal), want: []Change{{Path: "/plugins/local-ssh/source", Safety: Unsafe, Reason: ChangeReasonNewLocalSource, Value: srcLocalNew}}}, + {name: "source OCI without tag is local", overlay: `{"plugins":{"local-ssh":{"source":"ghcr.io/compliance-framework/p"}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/source", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: "ghcr.io/compliance-framework/p"}}}, + // Policies. + {name: "policies add an inline:-prefixed entry, a local path", overlay: `{"plugins":{"local-ssh":{"policies":["` + srcPolicies + `","` + srcCommon + `","inline:ssh-tuned"]}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policies", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: "inline:ssh-tuned"}}}, + {name: "policies add trusted OCI", overlay: `{"plugins":{"local-ssh":{"policies":["` + srcPolicies + `","ghcr.io/compliance-framework/extra:v1"]}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policies", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: "ghcr.io/compliance-framework/extra:v1"}}}, + { + name: "policies add several", + overlay: `{"plugins":{"local-ssh":{"policies":["` + srcUntrusted + `","ghcr.io/compliance-framework/extra:v1","` + srcLocalUsed + `"]}}}`, + rc: safe, + want: []Change{ + {Path: "/plugins/local-ssh/policies", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: srcLocalUsed}, + {Path: "/plugins/local-ssh/policies", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: "ghcr.io/compliance-framework/extra:v1"}, + {Path: "/plugins/local-ssh/policies", Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: srcUntrusted}, + }, + }, + {name: "policies removal", overlay: `{"plugins":{"local-ssh":{"policies":["` + srcCommon + `"]}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policies", Safety: Safe, Reason: ChangeReasonReducesScope}}}, + {name: "policies empty", overlay: `{"plugins":{"local-ssh":{"policies":[]}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policies", Safety: Safe, Reason: ChangeReasonReducesScope}}}, + {name: "policies null", overlay: `{"plugins":{"local-ssh":{"policies":null}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policies", Safety: Safe, Reason: ChangeReasonReducesScope}}}, + {name: "policies reorder", overlay: `{"plugins":{"local-ssh":{"policies":["` + srcCommon + `","` + srcPolicies + `"]}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/policies", Safety: Safe, Reason: ChangeReasonReducesScope}}}, + // Config. + {name: "config overridable key", overlay: `{"plugins":{"local-ssh":{"config":{"port":"2222"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/port", Safety: Safe, Reason: ChangeReasonOverridableConfigFlag}}}, + {name: "config not overridable", overlay: `{"plugins":{"local-ssh":{"config":{"host":"other"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/host", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}}}, + {name: "config null is a change", overlay: `{"plugins":{"local-ssh":{"config":{"port":null}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/port", Safety: Safe, Reason: ChangeReasonOverridableConfigFlag}}}, + {name: "config new key", overlay: `{"plugins":{"local-ssh":{"config":{"timeout":"5s"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/timeout", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}}}, + {name: "config star glob", overlay: `{"plugins":{"local-ssh":{"config":{"host":"other"}}}}`, rc: testRC(ModeApplySafe, overridable("*")), want: []Change{{Path: "/plugins/local-ssh/config/host", Safety: Safe, Reason: ChangeReasonOverridableConfigFlag}}}, + {name: "config default empty list", overlay: `{"plugins":{"local-ssh":{"config":{"port":"2222"}}}}`, rc: RemoteConfig{Mode: ModeApplySafe}.Normalize(true), want: []Change{{Path: "/plugins/local-ssh/config/port", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}}}, + {name: "config scoped glob other plugin", overlay: `{"plugins":{"disabled":{"config":{"port":"2222"}}}}`, rc: safe, want: []Change{{Path: "/plugins/disabled/config/port", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}}}, + {name: "config key pointer escaping", overlay: `{"plugins":{"local-ssh":{"config":{"a/b~c":"x"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/a~1b~0c", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}}}, + // Env references. + {name: "new env reference overrides overridable", overlay: `{"plugins":{"local-ssh":{"config":{"port":"${env:SSH_PORT}"}}}}`, rc: testRC(ModeApplySafe, overridable("*")), want: []Change{{Path: "/plugins/local-ssh/config/port", Safety: Unsafe, Reason: ChangeReasonNewEnvReference, Value: "SSH_PORT"}}}, + {name: "forbidden env reference", overlay: `{"plugins":{"local-ssh":{"config":{"host":"${env:CCF_API_AUTH_CLIENT_SECRET}"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/host", Safety: Forbidden, Reason: ChangeReasonForbiddenEnvReference, Value: "CCF_API_AUTH_CLIENT_SECRET"}}}, + { + name: "several new env references", + overlay: `{"plugins":{"local-ssh":{"config":{"host":"${env:B}.${env:A}.${env:ccf_api_auth_x}"}}}}`, + rc: safe, + want: []Change{ + {Path: "/plugins/local-ssh/config/host", Safety: Unsafe, Reason: ChangeReasonNewEnvReference, Value: "A"}, + {Path: "/plugins/local-ssh/config/host", Safety: Unsafe, Reason: ChangeReasonNewEnvReference, Value: "B"}, + {Path: "/plugins/local-ssh/config/host", Safety: Forbidden, Reason: ChangeReasonForbiddenEnvReference, Value: "ccf_api_auth_x"}, + }, + }, + {name: "same env reference already in base is not an env change", overlay: `{"plugins":{"local-ssh":{"config":{"password":"${env:SSH_PASSWORD}-2"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/password", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}}}, + {name: "same env reference already in base, overridable", overlay: `{"plugins":{"local-ssh":{"config":{"password":"${env:SSH_PASSWORD}-2"}}}}`, rc: testRC(ModeApplySafe, overridable("local-ssh:pass*")), want: []Change{{Path: "/plugins/local-ssh/config/password", Safety: Safe, Reason: ChangeReasonOverridableConfigFlag}}}, + {name: "env reference moved to another pointer is new", overlay: `{"plugins":{"local-ssh":{"config":{"port":"${env:SSH_PASSWORD}"}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/port", Safety: Unsafe, Reason: ChangeReasonNewEnvReference, Value: "SSH_PASSWORD"}}}, + {name: "env reference removed", overlay: `{"plugins":{"local-ssh":{"config":{"password":null}}}}`, rc: safe, want: []Change{{Path: "/plugins/local-ssh/config/password", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}}}, + // New plugin: the class of its parts. + { + name: "new plugin", + overlay: `{"plugins":{"new":{"source":"ghcr.io/compliance-framework/new:v1","schedule":"@hourly","config":{"port":"1"},"policies":["` + srcCommon + `"]}}}`, + rc: safe, + want: []Change{ + {Path: "/plugins/new/config/port", Safety: Unsafe, Reason: ChangeReasonConfigNotOverridable}, + {Path: "/plugins/new/policies", Safety: Safe, Reason: ChangeReasonAlreadyUsed, Value: srcCommon}, + {Path: "/plugins/new/schedule", Safety: Safe, Reason: ChangeReasonDataOnly}, + {Path: "/plugins/new/source", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: "ghcr.io/compliance-framework/new:v1"}, + }, + }, + // Mixed: locked key plus a real change. + { + name: "locked key and verbosity", + overlay: `{"api":{"url":"x"},"verbosity":1}`, + rc: safe, + want: []Change{ + {Path: "/api", Safety: Forbidden, Reason: ChangeReasonLockedKey}, + {Path: "/verbosity", Safety: Safe, Reason: ChangeReasonLogging}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base := classifyBase() + got, err := Classify(base, json.RawMessage(tt.overlay), tt.rc) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, classifyBase(), base, "base must not be mutated") + }) + } +} + +func TestClassifyErrors(t *testing.T) { + rc := testRC(ModeApplySafe) + for _, o := range []string{`[1]`, `"x"`, `{"a"`, `{"plugins":{"x":{"config":{"port":1}}}}`} { + _, err := Classify(classifyBase(), json.RawMessage(o), rc) + assert.Error(t, err, o) + } +} + +func TestWillApply(t *testing.T) { + safeC := Change{Path: "/verbosity", Safety: Safe, Reason: ChangeReasonLogging} + unsafeC := Change{Path: "/plugins/x/source", Safety: Unsafe, Reason: ChangeReasonUntrustedSource} + forbiddenC := Change{Path: "/api", Safety: Forbidden, Reason: ChangeReasonLockedKey} + + tests := []struct { + mode string + changes []Change + wantApply bool + wantReason string + }{ + {mode: ModeOff, changes: nil, wantApply: false, wantReason: WillApplyReasonModeOff}, + {mode: ModeOff, changes: []Change{safeC}, wantApply: false, wantReason: WillApplyReasonModeOff}, + {mode: "", changes: []Change{safeC}, wantApply: false, wantReason: WillApplyReasonModeOff}, + {mode: "bogus", changes: []Change{safeC}, wantApply: false, wantReason: WillApplyReasonModeOff}, + {mode: ModeReport, changes: nil, wantApply: false, wantReason: WillApplyReasonModeReport}, + {mode: ModeReport, changes: []Change{forbiddenC}, wantApply: false, wantReason: WillApplyReasonModeReport}, + {mode: ModeApplySafe, changes: nil, wantApply: true}, + {mode: ModeApplySafe, changes: []Change{safeC}, wantApply: true}, + {mode: ModeApplySafe, changes: []Change{safeC, unsafeC}, wantApply: false, wantReason: ReasonUnsafeChanges}, + {mode: ModeApplySafe, changes: []Change{forbiddenC}, wantApply: false, wantReason: ReasonForbiddenChanges}, + {mode: ModeApplySafe, changes: []Change{unsafeC, forbiddenC, safeC}, wantApply: false, wantReason: ReasonForbiddenChanges}, + {mode: ModeApplyAll, changes: nil, wantApply: true}, + {mode: ModeApplyAll, changes: []Change{safeC, unsafeC}, wantApply: true}, + {mode: ModeApplyAll, changes: []Change{safeC, forbiddenC}, wantApply: false, wantReason: ReasonForbiddenChanges}, + {mode: ModeApplyAll, changes: []Change{unsafeC, forbiddenC}, wantApply: false, wantReason: ReasonForbiddenChanges}, + } + for _, tt := range tests { + ok, reason := WillApply(RemoteConfig{Mode: tt.mode}, tt.changes) + assert.Equal(t, tt.wantApply, ok, "mode %q changes %v", tt.mode, tt.changes) + assert.Equal(t, tt.wantReason, reason, "mode %q changes %v", tt.mode, tt.changes) + } +} + +func TestClassifyThenWillApply(t *testing.T) { + overlay := json.RawMessage(`{"plugins":{"local-ssh":{"source":"` + srcLocalNew + `"}}}`) + + rcAll := testRC(ModeApplyAll, allowLocal) + changes, err := Classify(classifyBase(), overlay, rcAll) + require.NoError(t, err) + ok, reason := WillApply(rcAll, changes) + assert.True(t, ok) + assert.Empty(t, reason) + + rcSafe := testRC(ModeApplySafe, allowLocal) + changes, err = Classify(classifyBase(), overlay, rcSafe) + require.NoError(t, err) + ok, reason = WillApply(rcSafe, changes) + assert.False(t, ok) + assert.Equal(t, ReasonForbiddenChanges, reason) + + // No credentials: Normalize forces off. + rcNoAuth := RemoteConfig{Mode: ModeApplyAll}.Normalize(false) + ok, reason = WillApply(rcNoAuth, nil) + assert.False(t, ok) + assert.Equal(t, WillApplyReasonModeOff, reason) +} + +// Re-enabling a disabled plugin checks the policies and env references it already had: the +// host disabled it, so none of them is in use. +func TestClassifyReenableKeptParts(t *testing.T) { + base := Config{Plugins: map[string]*Plugin{"x": { + Enabled: boolPtr(false), + Source: "ghcr.io/trusted/p:v1", + Policies: []string{"ghcr.io/evil/pol:v9", "/tmp/local-policy"}, + Config: map[string]string{"host": "db", "token": "${env:DB_TOKEN}", "auth": "${env:CCF_API_AUTH_CLIENT_SECRET}"}, + }}} + rc := RemoteConfig{Mode: ModeApplySafe, TrustedSources: []string{"ghcr.io/trusted/*"}}.Normalize(true) + + changes, err := Classify(base, json.RawMessage(`{"plugins":{"x":{"enabled":true}}}`), rc) + require.NoError(t, err) + assert.Equal(t, []Change{ + {Path: "/plugins/x/config/auth", Safety: Forbidden, Reason: ChangeReasonForbiddenEnvReference, Value: "CCF_API_AUTH_CLIENT_SECRET"}, + {Path: "/plugins/x/config/token", Safety: Unsafe, Reason: ChangeReasonNewEnvReference, Value: "DB_TOKEN"}, + {Path: "/plugins/x/enabled", Safety: Safe, Reason: ChangeReasonTrustedSource, Value: "ghcr.io/trusted/p:v1"}, + {Path: "/plugins/x/policies", Safety: Forbidden, Reason: ChangeReasonLocalSourceNotAllowed, Value: "/tmp/local-policy"}, + {Path: "/plugins/x/policies", Safety: Unsafe, Reason: ChangeReasonUntrustedSource, Value: "ghcr.io/evil/pol:v9"}, + }, changes) + ok, reason := WillApply(rc, changes) + assert.False(t, ok) + assert.Equal(t, ReasonForbiddenChanges, reason) + + // An enabled plugin's kept references stay in use. + base.Plugins["x"].Enabled = nil + changes, err = Classify(base, json.RawMessage(`{"plugins":{"x":{"labels":{"a":"b"}}}}`), rc) + require.NoError(t, err) + assert.Equal(t, []Change{{Path: "/plugins/x/labels", Safety: Safe, Reason: ChangeReasonDataOnly}}, changes) +} diff --git a/pkg/agentconfig/diff.go b/pkg/agentconfig/diff.go index 23809125..1aac745e 100644 --- a/pkg/agentconfig/diff.go +++ b/pkg/agentconfig/diff.go @@ -87,6 +87,33 @@ func diffValues(path string, a, b any, hasA, hasB bool, out *[]DiffEntry) error return nil } +// leafDiffPaths returns the pointers of every changed leaf between two decoded documents. +// Unlike DiffJSON, an object present on one side only is expanded into its leaves (the +// missing side counts as an empty object), so "add plugins.x.policies" reports +// /plugins/x/policies rather than /plugins. A null or non-object on one side against an +// object on the other is reported at that path (it is a real type change). +func leafDiffPaths(path string, a, b any, hasA, hasB bool, out *[]string) { + objA, okA := a.(map[string]any) + objB, okB := b.(map[string]any) + switch { + case okA && !hasB: + objB, okB = map[string]any{}, true + case okB && !hasA: + objA, okA = map[string]any{}, true + } + if okA && okB { + for _, k := range unionKeys(objA, objB) { + va, inA := objA[k] + vb, inB := objB[k] + leafDiffPaths(appendPointer(path, k), va, vb, inA, inB, out) + } + return + } + if hasA != hasB || !jsonEqual(a, b) { + *out = append(*out, path) + } +} + func unionKeys(a, b map[string]any) []string { keys := make([]string, 0, len(a)+len(b)) for k := range a { diff --git a/pkg/agentconfig/errors.go b/pkg/agentconfig/errors.go new file mode 100644 index 00000000..e7e1dacf --- /dev/null +++ b/pkg/agentconfig/errors.go @@ -0,0 +1,55 @@ +package agentconfig + +import ( + "fmt" + "strings" +) + +// FieldError is one validation problem in a config document. +type FieldError struct { + Path string `json:"path"` // RFC 6901 pointer into the snake_case config ("" = root) + Code string `json:"code"` // stable machine code (R43), one of FieldCode* + Message string `json:"message"` // human text +} + +// FieldError codes (R43). The agent maps them to report reasons without its own pre-decode: +// unknown-field -> reason unknown-field; invalid-type -> invalid-type; env-missing -> +// env-missing; locked-key / forbidden-env -> forbidden-changes; everything else -> +// invalid-config. +const ( + FieldCodeUnknownField = "unknown-field" // key not in the schema (O4) + FieldCodeInvalidType = "invalid-type" // wrong JSON type, e.g. a non-string config/labels value (O5) + FieldCodeInvalidValue = "invalid-value" // right type, out of range / not in enum + FieldCodeLockedKey = "locked-key" // api / daemon / remote_config in an overlay (O3) + FieldCodeSize = "size" // overlay size limit (O2) + FieldCodePattern = "pattern" // plugin name or glob pattern (O6) + FieldCodeCron = "cron" // schedule does not parse (O7) + FieldCodeDuration = "duration" // interval / poll_interval + FieldCodeSource = "source" // empty plugin source or policy entry (O8) + FieldCodeEnvLocation = "env-location" // ${env:} outside plugins.*.config (O9) + FieldCodeForbiddenEnv = "forbidden-env" // ${env:CCF_API_AUTH_*} (O9) + FieldCodeEnvMissing = "env-missing" // ResolveEnv: variable unset (agent only) + FieldCodeMaskedValue = "masked-value" // "••••" submitted (O10) + FieldCodeRequired = "required" // missing required field (api.url, plugin source, ...) + FieldCodeParse = "parse" // not a JSON object +) + +// ValidationErrors is a list of FieldErrors; it is the error type returned by the +// validators. +type ValidationErrors []FieldError + +// Error implements error. +func (v ValidationErrors) Error() string { + if len(v) == 0 { + return "no validation errors" + } + parts := make([]string, 0, len(v)) + for _, e := range v { + path := e.Path + if path == "" { + path = "/" + } + parts = append(parts, fmt.Sprintf("%s: %s", path, e.Message)) + } + return strings.Join(parts, "; ") +} diff --git a/pkg/agentconfig/helpers_test.go b/pkg/agentconfig/helpers_test.go index 8a981e38..964d9e86 100644 --- a/pkg/agentconfig/helpers_test.go +++ b/pkg/agentconfig/helpers_test.go @@ -1,3 +1,5 @@ package agentconfig func strPtr(s string) *string { return &s } + +func boolPtr(b bool) *bool { return &b } diff --git a/pkg/agentconfig/remoteconfig_test.go b/pkg/agentconfig/remoteconfig_test.go new file mode 100644 index 00000000..6c20d2d1 --- /dev/null +++ b/pkg/agentconfig/remoteconfig_test.go @@ -0,0 +1,161 @@ +package agentconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalize(t *testing.T) { + t.Run("defaults with auth", func(t *testing.T) { + got := RemoteConfig{}.Normalize(true) + assert.Equal(t, RemoteConfig{ + Mode: ModeReport, + PollInterval: "60s", + TrustedSources: []string{}, + OverridableConfigFlags: []string{}, + AllowLocalSources: false, + }, got) + require.NotNil(t, got.TrustedSources, "[] not nil") + require.NotNil(t, got.OverridableConfigFlags, "[] not nil") + }) + t.Run("unset mode with auth reports, never applies", func(t *testing.T) { + rc := RemoteConfig{PollInterval: "2m"}.Normalize(true) + assert.Equal(t, ModeReport, rc.Mode) + apply, reason := WillApply(rc, nil) + assert.False(t, apply, "an agent that did not opt in never applies") + assert.Equal(t, WillApplyReasonModeReport, reason) + }) + t.Run("explicit modes kept with auth", func(t *testing.T) { + for _, mode := range []string{ModeOff, ModeReport, ModeApplySafe, ModeApplyAll} { + assert.Equal(t, mode, RemoteConfig{Mode: mode}.Normalize(true).Mode, mode) + } + }) + t.Run("no auth forces off", func(t *testing.T) { + for _, mode := range []string{"", ModeOff, ModeReport, ModeApplySafe, ModeApplyAll} { + assert.Equal(t, ModeOff, RemoteConfig{Mode: mode}.Normalize(false).Mode, mode) + } + }) + t.Run("explicit values kept", func(t *testing.T) { + in := RemoteConfig{ + Mode: ModeReport, + PollInterval: "5m", + TrustedSources: []string{"ghcr.io/*"}, + OverridableConfigFlags: []string{"*"}, + AllowLocalSources: true, + } + got := in.Normalize(true) + assert.Equal(t, ModeReport, got.Mode) + assert.Equal(t, "5m", got.PollInterval) + assert.Equal(t, []string{"ghcr.io/*"}, got.TrustedSources) + assert.Equal(t, []string{"*"}, got.OverridableConfigFlags) + assert.True(t, got.AllowLocalSources) + + // The result does not alias the input. + got.TrustedSources[0] = "changed" + assert.Equal(t, "ghcr.io/*", in.TrustedSources[0]) + }) + t.Run("empty slices stay empty", func(t *testing.T) { + got := RemoteConfig{TrustedSources: []string{}, OverridableConfigFlags: []string{}}.Normalize(true) + assert.Equal(t, []string{}, got.TrustedSources) + assert.Equal(t, []string{}, got.OverridableConfigFlags) + }) + t.Run("idempotent", func(t *testing.T) { + once := RemoteConfig{Mode: ModeApplyAll}.Normalize(true) + assert.Equal(t, once, once.Normalize(true)) + }) +} + +func TestEffectiveRemoteConfig(t *testing.T) { + withAuth := &APIConfig{URL: "http://x", Auth: &APIAuth{ClientID: "id", ClientSecret: "secret"}} + + assert.Equal(t, ModeReport, Config{API: withAuth}.EffectiveRemoteConfig().Mode, "unset mode defaults to report") + assert.Equal(t, ModeApplySafe, Config{API: withAuth, RemoteConfig: &RemoteConfig{Mode: ModeApplySafe}}.EffectiveRemoteConfig().Mode) + assert.Equal(t, ModeOff, Config{}.EffectiveRemoteConfig().Mode, "nil api") + assert.Equal(t, ModeOff, Config{API: &APIConfig{URL: "http://x"}}.EffectiveRemoteConfig().Mode, "no auth") + assert.Equal(t, ModeOff, Config{API: &APIConfig{URL: "http://x", Auth: &APIAuth{ClientID: "id", ClientSecret: " "}}}.EffectiveRemoteConfig().Mode, "blank secret") + assert.Equal(t, ModeOff, Config{API: &APIConfig{URL: "http://x"}, RemoteConfig: &RemoteConfig{Mode: ModeApplyAll}}.EffectiveRemoteConfig().Mode) + got := Config{API: withAuth, RemoteConfig: &RemoteConfig{Mode: ModeReport, PollInterval: "2m"}}.EffectiveRemoteConfig() + assert.Equal(t, ModeReport, got.Mode) + assert.Equal(t, "2m", got.PollInterval) +} + +func TestAPIConfigAuth(t *testing.T) { + tests := []struct { + name string + api *APIConfig + has, partia bool + }{ + {name: "nil", api: nil}, + {name: "no auth", api: &APIConfig{}}, + {name: "empty auth", api: &APIConfig{Auth: &APIAuth{}}}, + {name: "both", api: &APIConfig{Auth: &APIAuth{ClientID: "a", ClientSecret: "b"}}, has: true}, + {name: "id only", api: &APIConfig{Auth: &APIAuth{ClientID: "a"}}, partia: true}, + {name: "secret only", api: &APIConfig{Auth: &APIAuth{ClientSecret: "b"}}, partia: true}, + {name: "blank secret", api: &APIConfig{Auth: &APIAuth{ClientID: "a", ClientSecret: " "}}, partia: true}, + } + for _, tt := range tests { + assert.Equal(t, tt.has, tt.api.HasAuth(), tt.name) + assert.Equal(t, tt.partia, tt.api.HasPartialAuth(), tt.name) + } +} + +func TestPluginIsEnabled(t *testing.T) { + assert.False(t, (*Plugin)(nil).IsEnabled()) + assert.True(t, (&Plugin{}).IsEnabled()) + assert.True(t, (&Plugin{Enabled: boolPtr(true)}).IsEnabled()) + assert.False(t, (&Plugin{Enabled: boolPtr(false)}).IsEnabled()) +} + +func TestMatchTrustedSource(t *testing.T) { + rc := RemoteConfig{TrustedSources: []string{"ghcr.io/compliance-framework/*", "docker.io/acme/plugin-?:v1", "[bad"}} + tests := []struct { + source string + want bool + }{ + {"ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", true}, + {"ghcr.io/compliance-framework/sub/plugin:v1", false}, // '*' does not cross '/' + {"ghcr.io/Compliance-Framework/plugin:v1", false}, // case-sensitive + {"ghcr.io/compliance-framework", false}, + {"ghcr.io/other/plugin:v1", false}, + {"docker.io/acme/plugin-a:v1", true}, + {"docker.io/acme/plugin-ab:v1", false}, + {"", false}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, MatchTrustedSource(rc, tt.source), tt.source) + } + assert.False(t, MatchTrustedSource(RemoteConfig{}, "ghcr.io/x/y:v1"), "default [] trusts nothing") + assert.True(t, MatchTrustedSource(RemoteConfig{TrustedSources: []string{"*/*/*"}}, "ghcr.io/x/y:v1")) +} + +func TestMatchOverridableConfigFlag(t *testing.T) { + tests := []struct { + name string + flags []string + plugin string + key string + want bool + }{ + {name: "default empty", flags: nil, plugin: "local-ssh", key: "port", want: false}, + {name: "star", flags: []string{"*"}, plugin: "local-ssh", key: "port", want: true}, + {name: "star any plugin", flags: []string{"*"}, plugin: "other", key: "anything", want: true}, + {name: "scoped match", flags: []string{"local-ssh:port"}, plugin: "local-ssh", key: "port", want: true}, + {name: "scoped other key", flags: []string{"local-ssh:port"}, plugin: "local-ssh", key: "host", want: false}, + {name: "scoped other plugin", flags: []string{"local-ssh:port"}, plugin: "remote-ssh", key: "port", want: false}, + {name: "unscoped key any plugin", flags: []string{"port"}, plugin: "remote-ssh", key: "port", want: true}, + {name: "plugin glob", flags: []string{"*-ssh:port"}, plugin: "remote-ssh", key: "port", want: true}, + {name: "key glob", flags: []string{"local-ssh:tls_*"}, plugin: "local-ssh", key: "tls_verify", want: true}, + {name: "case-sensitive", flags: []string{"local-ssh:Port"}, plugin: "local-ssh", key: "port", want: false}, + {name: "split at first colon", flags: []string{"p*:a:b"}, plugin: "p1", key: "a:b", want: true}, + {name: "split at first colon, plugin side", flags: []string{"p*:a:b"}, plugin: "p1:a", key: "b", want: false}, + {name: "bad glob skipped", flags: []string{"[x:port", "local-ssh:[", "local-ssh:port"}, plugin: "local-ssh", key: "port", want: true}, + {name: "only bad globs", flags: []string{"[x:port", "local-ssh:["}, plugin: "local-ssh", key: "port", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, MatchOverridableConfigFlag(RemoteConfig{OverridableConfigFlags: tt.flags}, tt.plugin, tt.key)) + }) + } +} diff --git a/pkg/agentconfig/wire.go b/pkg/agentconfig/wire.go new file mode 100644 index 00000000..d2604230 --- /dev/null +++ b/pkg/agentconfig/wire.go @@ -0,0 +1,85 @@ +package agentconfig + +import ( + "encoding/json" + "time" +) + +// Instance statuses. The agent sends applied, rejected, failed or not-applicable (R10); the +// server additionally derives pending and unknown. +const ( + StatusApplied = "applied" + StatusRejected = "rejected" + StatusFailed = "failed" + StatusNotApplicable = "not-applicable" + StatusPending = "pending" // server-derived only + StatusUnknown = "unknown" // server-derived only +) + +// AgentStatuses are the statuses an agent may report. +var AgentStatuses = []string{StatusApplied, StatusRejected, StatusFailed, StatusNotApplicable} + +// Report reasons (R42). The API rejects any other value. +const ( + ReasonUnsafeChanges = "unsafe-changes" + ReasonForbiddenChanges = "forbidden-changes" + ReasonInvalidConfig = "invalid-config" // overlay-merged config invalid, OR the local file is invalid on reload (agent keeps last-known-good, R42) + ReasonInvalidType = "invalid-type" // R27 (agent strict decode) + ReasonUnknownField = "unknown-field" // R27 + ReasonDownloadFailed = "download-failed" + ReasonEnvMissing = "env-missing" // R24 + ReasonUnsupportedByAgent = "unsupported-by-agent" // overlay uses a feature this agent version lacks + ReasonCacheCorrupt = "cache-corrupt" + ReasonInternal = "internal" +) + +// Reasons is the full report reason vocabulary. +var Reasons = []string{ + ReasonUnsafeChanges, ReasonForbiddenChanges, ReasonInvalidConfig, ReasonInvalidType, + ReasonUnknownField, ReasonDownloadFailed, ReasonEnvMissing, + ReasonUnsupportedByAgent, ReasonCacheCorrupt, ReasonInternal, +} + +// Modes is the remote_config.mode vocabulary. +var Modes = []string{ModeOff, ModeReport, ModeApplySafe, ModeApplyAll} + +// OverlayDocument is the body of GET /api/agent/config (inside {"data": ...}). +type OverlayDocument struct { + Revision int64 `json:"revision"` + Overlay json.RawMessage `json:"overlay" swaggertype:"object"` + CreatedAt *time.Time `json:"created-at,omitempty"` // omitted for revision 0 +} + +// Report is the body of PUT /api/agent/instances/:instanceId/config-report. Envelope keys are +// kebab-case; Base, Effective and RemoteConfig contents are snake_case config documents. +type Report struct { + Hostname string `json:"hostname,omitempty"` // <= 255 + AgentVersion string `json:"agent-version,omitempty"` // <= 64 + Mode string `json:"mode"` + Daemon bool `json:"daemon"` // false = one-shot run; pruned after 24h (R10, R37) + AppliedRevision *int64 `json:"applied-revision"` + AttemptedRevision *int64 `json:"attempted-revision,omitempty"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Error *string `json:"error"` // <= 8 KiB, truncated server-side + Truncated bool `json:"truncated,omitempty"` // agent dropped/trimmed parts to fit MaxReportBytes (R10) + Warnings []FieldError `json:"warnings,omitempty"` // R41: tolerated file-origin problems + Base json.RawMessage `json:"base" swaggertype:"object"` + Effective json.RawMessage `json:"effective" swaggertype:"object"` + EffectiveDigest string `json:"effective-digest"` + Unsafe []Change `json:"unsafe,omitempty"` + RemoteConfig *RemoteConfig `json:"remote-config,omitempty"` // normalized; snake_case inside + // Plugins are the instance's plugins and the agent library each was built with (R76), + // so the UI can show policy compatibility before a save. Older agents omit it. + Plugins []PluginReport `json:"plugins,omitempty"` +} + +// PluginReport is one plugin of an instance (R76). +type PluginReport struct { + Name string `json:"name"` // the plugin's key under plugins in the config + Source string `json:"source,omitempty"` // the configured source + // LibVersion is the version of github.com/compliance-framework/agent the plugin binary + // was built with, from its Go build info. Empty when unknown: no build info, or a + // replace or devel build. + LibVersion string `json:"lib-version,omitempty"` +}