From 2b1b4d4fcf8c26e26576c552d4a215d5acb5d6e1 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:02:36 -0300 Subject: [PATCH 1/2] feat(agentconfig): overlay validation Fifth layer of the agent remote-configuration stack (split from #465): ValidateOverlay (strict decode, size, locked keys, plugin names, env references, schedules incl. the TZ= prefix guard, NUL characters) and the ValidationErrors/FieldError vocabulary. pkg/agentconfig is complete after this layer. Co-Authored-By: Claude Opus 5.5 --- pkg/agentconfig/cron_test.go | 25 ++ pkg/agentconfig/errors.go | 19 ++ pkg/agentconfig/helpers_test.go | 36 +++ pkg/agentconfig/validate.go | 493 +++++++++++++++++++++++++++++++ pkg/agentconfig/validate_test.go | 348 ++++++++++++++++++++++ 5 files changed, 921 insertions(+) create mode 100644 pkg/agentconfig/cron_test.go create mode 100644 pkg/agentconfig/validate.go create mode 100644 pkg/agentconfig/validate_test.go diff --git a/pkg/agentconfig/cron_test.go b/pkg/agentconfig/cron_test.go new file mode 100644 index 00000000..9ec564ca --- /dev/null +++ b/pkg/agentconfig/cron_test.go @@ -0,0 +1,25 @@ +package agentconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScheduleTimeZonePrefix(t *testing.T) { + for _, expr := range []string{"TZ=UTC 0 * * * *", "CRON_TZ=Europe/London 0 * * * *"} { + _, err := ParseSchedule(expr) + assert.NoError(t, err, expr) + } + // robfig/cron panics on these; ParseSchedule must return an error instead. + for _, expr := range []string{"TZ=UTC", "CRON_TZ=UTC", "TZ=", "CRON_TZ="} { + require.NotPanics(t, func() { + _, err := ParseSchedule(expr) + assert.Error(t, err, expr) + }, expr) + } + // The overlay rule O7 reports it as a validation error, not a crash. + err := ValidateOverlay([]byte(`{"plugins":{"p":{"schedule":"TZ=UTC"}}}`)) + requireFieldError(t, err, "/plugins/p/schedule", FieldCodeCron) +} diff --git a/pkg/agentconfig/errors.go b/pkg/agentconfig/errors.go index e7e1dacf..bc1a355d 100644 --- a/pkg/agentconfig/errors.go +++ b/pkg/agentconfig/errors.go @@ -1,7 +1,9 @@ package agentconfig import ( + "cmp" "fmt" + "slices" "strings" ) @@ -53,3 +55,20 @@ func (v ValidationErrors) Error() string { } return strings.Join(parts, "; ") } + +// sortFieldErrors orders errors by path, then code, then message, and drops exact +// duplicates. +func sortFieldErrors(errs []FieldError) []FieldError { + slices.SortFunc(errs, func(a, b FieldError) int { + return cmp.Or(strings.Compare(a.Path, b.Path), strings.Compare(a.Code, b.Code), strings.Compare(a.Message, b.Message)) + }) + return slices.Compact(errs) +} + +// asError returns nil for an empty list and the sorted ValidationErrors otherwise. +func asError(errs []FieldError) error { + if len(errs) == 0 { + return nil + } + return ValidationErrors(sortFieldErrors(errs)) +} diff --git a/pkg/agentconfig/helpers_test.go b/pkg/agentconfig/helpers_test.go index 964d9e86..dd898a96 100644 --- a/pkg/agentconfig/helpers_test.go +++ b/pkg/agentconfig/helpers_test.go @@ -1,5 +1,41 @@ package agentconfig +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + func strPtr(s string) *string { return &s } func boolPtr(b bool) *bool { return &b } + +// fieldErrors asserts err is a ValidationErrors and returns it. +func fieldErrors(t *testing.T, err error) ValidationErrors { + t.Helper() + require.Error(t, err) + var ve ValidationErrors + require.True(t, errors.As(err, &ve), "error is %T, want ValidationErrors: %v", err, err) + require.NotEmpty(t, ve) + return ve +} + +// findFieldError returns the first error at path with code, or nil. +func findFieldError(errs ValidationErrors, path, code string) *FieldError { + for i := range errs { + if errs[i].Path == path && errs[i].Code == code { + return &errs[i] + } + } + return nil +} + +// requireFieldError asserts that err contains a FieldError with path and code. +func requireFieldError(t *testing.T, err error, path, code string) FieldError { + t.Helper() + errs := fieldErrors(t, err) + fe := findFieldError(errs, path, code) + require.NotNil(t, fe, "no FieldError {path %q, code %q} in %#v", path, code, errs) + return *fe +} diff --git a/pkg/agentconfig/validate.go b/pkg/agentconfig/validate.go new file mode 100644 index 00000000..beaf2fb9 --- /dev/null +++ b/pkg/agentconfig/validate.go @@ -0,0 +1,493 @@ +package agentconfig + +import ( + "bytes" + "encoding/json" + "fmt" + "path" + "slices" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +// ValidateOverlay validates an overlay ON ITS OWN (no base) and returns nil or +// ValidationErrors. It is the only strict decoder in the package (R27, R51): unknown keys are +// rejected everywhere, every leaf may be null (RFC 7396 delete) and there is no type +// coercion. Rules: +// +// O1 must be a JSON object ({} allowed) +// O2 compact size <= MaxOverlayBytes +// O3 no locked key (api, daemon, remote_config), even with a null value +// O4 unknown keys are rejected +// O5 types: verbosity integer 0-2; agent_evidence.{enabled,emit_on_run_completion} bool, +// interval a Go duration >= 0; plugins.*.config and labels values strings (or null); +// policy_behavior values string arrays; protocol_version 1 or 2 (explicit 0 rejected, +// R9); schedule a string +// O6 every non-null plugin key in the overlay matches PluginNamePattern, also for a file +// plugin the overlay only changes (there is no base here); so a file plugin whose name +// does not match (e.g. "_legacy", or longer than 63 characters) cannot be changed +// remotely, only deleted with null +// O7 schedule parses with ParseSchedule +// O8 source (when non-null) and policy entries are non-empty +// O9 ${env:NAME} only in plugins.*.config values; NAME must not be forbidden +// O10 no string value equals MaskedValue +// O11 no key or string value contains a NUL character (Postgres cannot store it) +func ValidateOverlay(overlay json.RawMessage) error { + v, err := decodeAny(overlay) + if err != nil { + return ValidationErrors{{Path: "", Code: FieldCodeParse, Message: fmt.Sprintf("overlay is not valid JSON: %s", err.Error())}} + } + obj, ok := v.(map[string]any) + if !ok { + return ValidationErrors{{Path: "", Code: FieldCodeParse, Message: "overlay must be a JSON object"}} + } + + ov := &overlayValidator{} + + // O2: size of the compact encoding. + var compact bytes.Buffer + if err := json.Compact(&compact, overlay); err == nil && compact.Len() > MaxOverlayBytes { + ov.add("", FieldCodeSize, "overlay is %d bytes; the limit is %d", compact.Len(), MaxOverlayBytes) + } + + for _, key := range sortedKeys(obj) { + val := obj[key] + ptr := Pointer(key) + switch key { + case "api", "daemon", "remote_config": + ov.add(ptr, FieldCodeLockedKey, "%s is set locally only and cannot be changed remotely", key) + case "verbosity": + if val != nil { + if n, ok := ov.integer(ptr, val); ok && (n < 0 || n > 2) { + ov.add(ptr, FieldCodeInvalidValue, "must be 0, 1 or 2") + } + } + case "plugins": + ov.plugins(ptr, val) + case "agent_evidence": + ov.agentEvidence(ptr, val) + default: + ov.add(ptr, FieldCodeUnknownField, "unknown field %q", key) + } + } + + // O11: NUL in a key. + walkKeys("", obj, func(ptr, k string) { + if strings.ContainsRune(k, 0) { + ov.add(ptr, FieldCodeInvalidValue, "keys must not contain a NUL character") + } + }) + + // O9, O10 and O11 apply to every string in the document. + walkStrings("", obj, func(ptr, s string) { + if strings.ContainsRune(s, 0) { + ov.add(ptr, FieldCodeInvalidValue, "must not contain a NUL character") + } + if s == MaskedValue { + ov.add(ptr, FieldCodeMaskedValue, "redacted placeholder %q cannot be submitted; set the real value or omit the key", MaskedValue) + } + ov.envRefs(ptr, s, isPluginConfigValuePointer(ptr)) + }) + + return asError(ov.errs) +} + +type overlayValidator struct { + errs []FieldError +} + +func (ov *overlayValidator) add(ptr, code, format string, args ...any) { + ov.errs = append(ov.errs, FieldError{Path: ptr, Code: code, Message: fmt.Sprintf(format, args...)}) +} + +func (ov *overlayValidator) object(ptr string, v any) (map[string]any, bool) { + obj, ok := v.(map[string]any) + if !ok { + ov.add(ptr, FieldCodeInvalidType, "must be an object") + } + return obj, ok +} + +func (ov *overlayValidator) str(ptr string, v any) (string, bool) { + s, ok := v.(string) + if !ok { + ov.add(ptr, FieldCodeInvalidType, "must be a string") + } + return s, ok +} + +func (ov *overlayValidator) boolean(ptr string, v any) { + if _, ok := v.(bool); !ok { + ov.add(ptr, FieldCodeInvalidType, "must be a boolean") + } +} + +func (ov *overlayValidator) integer(ptr string, v any) (int64, bool) { + n, ok := v.(json.Number) + if ok { + if i, err := n.Int64(); err == nil { + return i, true + } + } + ov.add(ptr, FieldCodeInvalidType, "must be an integer") + return 0, false +} + +// stringMap checks an object whose values must be strings or null. +func (ov *overlayValidator) stringMap(ptr string, v any) { + if v == nil { + return + } + obj, ok := ov.object(ptr, v) + if !ok { + return + } + for _, k := range sortedKeys(obj) { + if obj[k] != nil { + ov.str(appendPointer(ptr, k), obj[k]) + } + } +} + +// stringArray checks an array of strings and returns them (nil for null or invalid). +func (ov *overlayValidator) stringArray(ptr string, v any) ([]string, bool) { + if v == nil { + return nil, true + } + arr, ok := v.([]any) + if !ok { + ov.add(ptr, FieldCodeInvalidType, "must be an array of strings") + return nil, false + } + out := make([]string, 0, len(arr)) + valid := true + for i, item := range arr { + s, ok := ov.str(appendPointer(ptr, strconv.Itoa(i)), item) + if !ok { + valid = false + continue + } + out = append(out, s) + } + return out, valid +} + +func (ov *overlayValidator) plugins(ptr string, v any) { + if v == nil { + return + } + obj, ok := ov.object(ptr, v) + if !ok { + return + } + for _, name := range sortedKeys(obj) { + pptr := appendPointer(ptr, name) + val := obj[name] + if val == nil { + continue // RFC 7396: delete the plugin (reduces scope) + } + if !PluginNamePattern.MatchString(name) { + ov.add(pptr, FieldCodePattern, "plugin name %q must match %s", name, PluginNamePattern.String()) + } + plugin, ok := ov.object(pptr, val) + if !ok { + continue + } + for _, key := range sortedKeys(plugin) { + fv := plugin[key] + fptr := appendPointer(pptr, key) + if fv == nil { + switch key { + case "enabled", "protocol_version", "schedule", "source", "policies", "config", "labels", "policy_data", "policy_behavior": + continue // null deletes the key; the agent default applies + } + } + switch key { + case "enabled": + ov.boolean(fptr, fv) + case "protocol_version": + if n, ok := ov.integer(fptr, fv); ok && n != 1 && n != 2 { + if n == 0 { + ov.add(fptr, FieldCodeInvalidValue, "must be 1 or 2; omit the key to keep the file value or send null for auto-detection") + } else { + ov.add(fptr, FieldCodeInvalidValue, "must be 1 or 2") + } + } + case "schedule": + if s, ok := ov.str(fptr, fv); ok { + if _, err := ParseSchedule(s); err != nil { + ov.add(fptr, FieldCodeCron, "invalid cron schedule: %s", err.Error()) + } + } + case "source": + if s, ok := ov.str(fptr, fv); ok { + ov.pluginSource(fptr, s) + } + case "policies": + entries, _ := ov.stringArray(fptr, fv) + for i, e := range entries { + ov.policyEntry(appendPointer(fptr, strconv.Itoa(i)), e) + } + case "config", "labels": + ov.stringMap(fptr, fv) + case "policy_data": + ov.object(fptr, fv) + case "policy_behavior": + if behavior, ok := ov.object(fptr, fv); ok { + for _, k := range sortedKeys(behavior) { + ov.stringArray(appendPointer(fptr, k), behavior[k]) + } + } + default: + ov.add(fptr, FieldCodeUnknownField, "unknown field %q", key) + } + } + } +} + +func (ov *overlayValidator) pluginSource(ptr, s string) { + if strings.TrimSpace(s) == "" { + ov.add(ptr, FieldCodeSource, "plugin source must not be empty") + } +} + +func (ov *overlayValidator) policyEntry(ptr, e string) { + if strings.TrimSpace(e) == "" { + ov.add(ptr, FieldCodeSource, "policy entry must not be empty") + } +} + +func (ov *overlayValidator) agentEvidence(ptr string, v any) { + if v == nil { + return + } + obj, ok := ov.object(ptr, v) + if !ok { + return + } + for _, key := range sortedKeys(obj) { + fv := obj[key] + fptr := appendPointer(ptr, key) + switch key { + case "enabled", "emit_on_run_completion": + if fv != nil { + ov.boolean(fptr, fv) + } + case "interval": + if fv == nil { + continue + } + if s, ok := ov.str(fptr, fv); ok { + if msg := checkDuration(s, 0); msg != "" { + ov.add(fptr, FieldCodeDuration, "%s", msg) + } + } + default: + ov.add(fptr, FieldCodeUnknownField, "unknown field %q", key) + } + } +} + +// envRefs applies O9 to one string value. +func (ov *overlayValidator) envRefs(ptr, s string, inPluginConfig bool) { + names := EnvRefs(s) + if len(names) == 0 { + return + } + if !inPluginConfig { + ov.add(ptr, FieldCodeEnvLocation, "${env:...} references are only resolved in plugins.*.config values") + return + } + for _, n := range names { + if IsForbiddenEnvName(n) { + ov.add(ptr, FieldCodeForbiddenEnv, "${env:%s} may not be referenced", n) + } + } +} + +// isPluginConfigValuePointer reports whether ptr is exactly /plugins/

/config/. +func isPluginConfigValuePointer(ptr string) bool { + segs := SplitPointer(ptr) + return len(segs) == 4 && segs[0] == "plugins" && segs[2] == "config" +} + +// walkStrings calls fn for every string value in a decoded JSON tree (object keys are not +// visited), with the value's pointer. +func walkStrings(ptr string, v any, fn func(ptr, s string)) { + switch t := v.(type) { + case string: + fn(ptr, t) + case map[string]any: + for _, k := range sortedKeys(t) { + walkStrings(appendPointer(ptr, k), t[k], fn) + } + case []any: + for i, item := range t { + walkStrings(appendPointer(ptr, strconv.Itoa(i)), item, fn) + } + } +} + +// walkKeys calls fn for every object key in a decoded JSON tree, with the key's pointer. +func walkKeys(ptr string, v any, fn func(ptr, key string)) { + switch t := v.(type) { + case map[string]any: + for _, k := range sortedKeys(t) { + kptr := appendPointer(ptr, k) + fn(kptr, k) + walkKeys(kptr, t[k], fn) + } + case []any: + for i, item := range t { + walkKeys(appendPointer(ptr, strconv.Itoa(i)), item, fn) + } + } +} + +// checkDuration returns "" when s is a Go duration >= min, else a message. +func checkDuration(s string, min time.Duration) string { + d, err := time.ParseDuration(strings.TrimSpace(s)) + if err != nil { + return fmt.Sprintf("must be a duration such as 30s or 5m: %s", err.Error()) + } + if d < min { + if min == 0 { + return "must not be negative" + } + return fmt.Sprintf("must be at least %s", min) + } + return "" +} + +// ValidateEditable checks an effective config except the locked blocks (api, daemon, +// remote_config). The API uses it on redacted reported bases merged with an overlay, so it +// never rejects masked values or a missing client secret. Rules: verbosity >= 0; +// agent_evidence.interval a non-negative duration; every plugin non-nil with a non-empty +// source, a parseable schedule, protocol_version in {0,1,2} and non-empty policy entries; +// env references obey O9. +func (c Config) ValidateEditable() error { + return asError(c.validateEditable()) +} + +// Validate is the agent's full check of an effective config: ValidateEditable plus the api +// block (url required, both or neither credential, client_id a UUID) and remote_config (mode +// enum, poll_interval >= MinPollInterval, valid glob patterns). File-origin leniency (R34) +// and the explicit-0 protocol_version file check (R9) are agent concerns: the agent chooses +// which FieldErrors to downgrade. +func (c Config) Validate() error { + errs := c.validateEditable() + errs = append(errs, c.validateAPI()...) + errs = append(errs, c.validateRemoteConfig()...) + return asError(errs) +} + +func (c Config) validateEditable() []FieldError { + ov := &overlayValidator{} + if c.Verbosity < 0 { + ov.add("/verbosity", FieldCodeInvalidValue, "must not be negative") + } + if c.AgentEvidence != nil && strings.TrimSpace(c.AgentEvidence.Interval) != "" { + if msg := checkDuration(c.AgentEvidence.Interval, 0); msg != "" { + ov.add("/agent_evidence/interval", FieldCodeDuration, "%s", msg) + } + } + for _, name := range sortedKeys(c.Plugins) { + p := c.Plugins[name] + pptr := Pointer("plugins", name) + if p == nil { + ov.add(pptr, FieldCodeRequired, "plugin %q has no configuration", name) + continue + } + if strings.TrimSpace(p.Source) == "" { + ov.add(pptr+"/source", FieldCodeRequired, "plugin source is required") + } + if p.Schedule != nil { + if _, err := ParseSchedule(*p.Schedule); err != nil { + ov.add(pptr+"/schedule", FieldCodeCron, "invalid cron schedule: %s", err.Error()) + } + } + if p.ProtocolVersion < 0 || p.ProtocolVersion > 2 { + ov.add(pptr+"/protocol_version", FieldCodeInvalidValue, "must be 1 or 2 (0 or unset = auto)") + } + for i, e := range p.Policies { + ov.policyEntry(pptr+"/policies/"+strconv.Itoa(i), e) + } + } + + // O9 over the editable part of the document. + if raw, err := json.Marshal(c.clone().editableView()); err == nil { + if doc, err := decodeAny(raw); err == nil { + walkStrings("", doc, func(ptr, s string) { + ov.envRefs(ptr, s, isPluginConfigValuePointer(ptr)) + }) + } + } + return ov.errs +} + +// editableView is c without the locked blocks. +func (c Config) editableView() Config { + out := c + out.API = nil + out.RemoteConfig = nil + out.Daemon = false + return out +} + +func (c Config) validateAPI() []FieldError { + ov := &overlayValidator{} + switch { + case c.API == nil: + ov.add("/api", FieldCodeRequired, "no api config specified") + return ov.errs + case strings.TrimSpace(c.API.URL) == "": + ov.add("/api/url", FieldCodeRequired, "api url must be configured") + } + if c.API.HasPartialAuth() { + ov.add("/api/auth", FieldCodeRequired, "api auth requires both client_id and client_secret when configured") + } + if c.API.HasAuth() { + if _, err := uuid.Parse(strings.TrimSpace(c.API.Auth.ClientID)); err != nil { + ov.add("/api/auth/client_id", FieldCodeInvalidValue, "api auth client_id must be a valid UUID") + } + } + return ov.errs +} + +func (c Config) validateRemoteConfig() []FieldError { + ov := &overlayValidator{} + rc := c.RemoteConfig + if rc == nil { + return nil + } + if rc.Mode != "" && !slices.Contains([]string{ModeOff, ModeReport, ModeApplySafe, ModeApplyAll}, rc.Mode) { + ov.add("/remote_config/mode", FieldCodeInvalidValue, "mode must be one of off, report, apply_safe, apply_all") + } + if strings.TrimSpace(rc.PollInterval) != "" { + if msg := checkDuration(rc.PollInterval, MinPollInterval); msg != "" { + ov.add("/remote_config/poll_interval", FieldCodeDuration, "%s", msg) + } + } + for i, p := range rc.TrustedSources { + if _, err := path.Match(p, ""); err != nil { + ov.add("/remote_config/trusted_sources/"+strconv.Itoa(i), FieldCodePattern, "invalid glob pattern %q", p) + } + } + for i, entry := range rc.OverridableConfigFlags { + pluginGlob, keyGlob, scoped := strings.Cut(entry, ":") + globs := []string{entry} + if scoped { + globs = []string{pluginGlob, keyGlob} + } + for _, g := range globs { + if _, err := path.Match(g, ""); err != nil { + ov.add("/remote_config/overridable_config_flags/"+strconv.Itoa(i), FieldCodePattern, "invalid glob pattern %q", entry) + break + } + } + } + return ov.errs +} diff --git a/pkg/agentconfig/validate_test.go b/pkg/agentconfig/validate_test.go new file mode 100644 index 00000000..1749b90c --- /dev/null +++ b/pkg/agentconfig/validate_test.go @@ -0,0 +1,348 @@ +package agentconfig + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateOverlayValid(t *testing.T) { + overlays := []string{ + `{}`, + `{"verbosity":0}`, + `{"verbosity":2}`, + `{"verbosity":null}`, + `{"agent_evidence":{"enabled":true,"emit_on_run_completion":false,"interval":"5m"}}`, + `{"agent_evidence":{"enabled":null,"emit_on_run_completion":null,"interval":null}}`, + `{"agent_evidence":{"interval":"0s"}}`, + `{"agent_evidence":null}`, + `{"plugins":null}`, + `{"plugins":{"local-ssh":null}}`, + `{"plugins":{"GitHub":null}}`, // pattern is checked only for non-null plugin entries + `{"plugins":{"local-ssh":{ + "enabled": false, + "protocol_version": 2, + "schedule": "*/5 * * * *", + "source": "ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", + "policies": ["ghcr.io/compliance-framework/plugin-local-ssh-policies:v1.0.0", "./policies/extra"], + "config": {"port": "2222", "host": null, "password": "${env:SSH_PASSWORD}", "dsn": "user=${env:U}@h"}, + "labels": {"env": "prod", "old": null}, + "policy_data": {"threshold": 5, "nested": {"a": [1, true]}}, + "policy_behavior": {"deny": ["warn"], "old": null} + }}}`, + `{"plugins":{"x":{"protocol_version":1}}}`, + `{"plugins":{"x":{"protocol_version":null}}}`, + `{"plugins":{"x":{"schedule":"@hourly"}}}`, + `{"plugins":{"x":{"schedule":null}}}`, + `{"plugins":{"x":{"enabled":null,"source":null,"policies":null,"config":null,"labels":null,"policy_data":null,"policy_behavior":null}}}`, + `{"plugins":{"x":{"policies":[]}}}`, + } + for _, o := range overlays { + t.Run(o, func(t *testing.T) { + assert.NoError(t, ValidateOverlay(json.RawMessage(o))) + }) + } +} + +func TestValidateOverlayRules(t *testing.T) { + tests := []struct { + name string + overlay string + path string + code string + contains string + }{ + // O1 + {name: "O1 array", overlay: `[]`, path: "", code: FieldCodeParse}, + {name: "O1 string", overlay: `"x"`, path: "", code: FieldCodeParse}, + {name: "O1 null", overlay: `null`, path: "", code: FieldCodeParse}, + {name: "O1 invalid json", overlay: `{"a":`, path: "", code: FieldCodeParse}, + // O3 + {name: "O3 api", overlay: `{"api":{"url":"x"}}`, path: "/api", code: FieldCodeLockedKey}, + {name: "O3 api null", overlay: `{"api":null}`, path: "/api", code: FieldCodeLockedKey}, + {name: "O3 daemon", overlay: `{"daemon":true}`, path: "/daemon", code: FieldCodeLockedKey}, + {name: "O3 daemon null", overlay: `{"daemon":null}`, path: "/daemon", code: FieldCodeLockedKey}, + {name: "O3 remote_config", overlay: `{"remote_config":{"mode":"apply_all"}}`, path: "/remote_config", code: FieldCodeLockedKey}, + {name: "O3 remote_config null", overlay: `{"remote_config":null}`, path: "/remote_config", code: FieldCodeLockedKey}, + // O4 + {name: "O4 unknown root key plugin", overlay: `{"plugin":{}}`, path: "/plugin", code: FieldCodeUnknownField}, + {name: "O4 unknown root key null", overlay: `{"foo":null}`, path: "/foo", code: FieldCodeUnknownField}, + {name: "O4 case matters", overlay: `{"Plugins":{}}`, path: "/Plugins", code: FieldCodeUnknownField}, + {name: "O4 unknown plugin key", overlay: `{"plugins":{"x":{"sorce":"s"}}}`, path: "/plugins/x/sorce", code: FieldCodeUnknownField}, + {name: "O4 unknown plugin key null", overlay: `{"plugins":{"x":{"sorce":null}}}`, path: "/plugins/x/sorce", code: FieldCodeUnknownField}, + {name: "O4 unknown agent_evidence key", overlay: `{"agent_evidence":{"on":true}}`, path: "/agent_evidence/on", code: FieldCodeUnknownField}, + {name: "O4 policy_bundles is unknown", overlay: `{"policy_bundles":{"b":{"modules":{}}}}`, path: "/policy_bundles", code: FieldCodeUnknownField}, + {name: "O4 policy_bundles null is unknown", overlay: `{"policy_bundles":null}`, path: "/policy_bundles", code: FieldCodeUnknownField}, + // O5 + {name: "O5 config number", overlay: `{"plugins":{"x":{"config":{"port":2222}}}}`, path: "/plugins/x/config/port", code: FieldCodeInvalidType, contains: "must be a string"}, + {name: "O5 config bool", overlay: `{"plugins":{"x":{"config":{"tls":false}}}}`, path: "/plugins/x/config/tls", code: FieldCodeInvalidType, contains: "must be a string"}, + {name: "O5 config object", overlay: `{"plugins":{"x":{"config":{"a":{}}}}}`, path: "/plugins/x/config/a", code: FieldCodeInvalidType, contains: "must be a string"}, + {name: "O5 config not object", overlay: `{"plugins":{"x":{"config":"a=b"}}}`, path: "/plugins/x/config", code: FieldCodeInvalidType}, + {name: "O5 labels bool", overlay: `{"plugins":{"x":{"labels":{"env":true}}}}`, path: "/plugins/x/labels/env", code: FieldCodeInvalidType, contains: "must be a string"}, + {name: "O5 verbosity string", overlay: `{"verbosity":"1"}`, path: "/verbosity", code: FieldCodeInvalidType}, + {name: "O5 verbosity float", overlay: `{"verbosity":1.5}`, path: "/verbosity", code: FieldCodeInvalidType}, + {name: "O5 verbosity too big", overlay: `{"verbosity":3}`, path: "/verbosity", code: FieldCodeInvalidValue}, + {name: "O5 verbosity negative", overlay: `{"verbosity":-1}`, path: "/verbosity", code: FieldCodeInvalidValue}, + {name: "O5 evidence enabled string", overlay: `{"agent_evidence":{"enabled":"yes"}}`, path: "/agent_evidence/enabled", code: FieldCodeInvalidType}, + {name: "O5 evidence emit number", overlay: `{"agent_evidence":{"emit_on_run_completion":1}}`, path: "/agent_evidence/emit_on_run_completion", code: FieldCodeInvalidType}, + {name: "O5 evidence interval bad", overlay: `{"agent_evidence":{"interval":"soon"}}`, path: "/agent_evidence/interval", code: FieldCodeDuration}, + {name: "O5 evidence interval negative", overlay: `{"agent_evidence":{"interval":"-1s"}}`, path: "/agent_evidence/interval", code: FieldCodeDuration}, + {name: "O5 evidence interval number", overlay: `{"agent_evidence":{"interval":60}}`, path: "/agent_evidence/interval", code: FieldCodeInvalidType}, + {name: "O5 evidence not object", overlay: `{"agent_evidence":true}`, path: "/agent_evidence", code: FieldCodeInvalidType}, + {name: "O5 enabled string", overlay: `{"plugins":{"x":{"enabled":"true"}}}`, path: "/plugins/x/enabled", code: FieldCodeInvalidType}, + {name: "O5 protocol_version explicit 0", overlay: `{"plugins":{"x":{"protocol_version":0}}}`, path: "/plugins/x/protocol_version", code: FieldCodeInvalidValue, contains: "null"}, + {name: "O5 protocol_version 3", overlay: `{"plugins":{"x":{"protocol_version":3}}}`, path: "/plugins/x/protocol_version", code: FieldCodeInvalidValue}, + {name: "O5 protocol_version string", overlay: `{"plugins":{"x":{"protocol_version":"2"}}}`, path: "/plugins/x/protocol_version", code: FieldCodeInvalidType}, + {name: "O5 schedule number", overlay: `{"plugins":{"x":{"schedule":5}}}`, path: "/plugins/x/schedule", code: FieldCodeInvalidType}, + {name: "O5 policy_behavior value not array", overlay: `{"plugins":{"x":{"policy_behavior":{"deny":"warn"}}}}`, path: "/plugins/x/policy_behavior/deny", code: FieldCodeInvalidType}, + {name: "O5 policy_behavior item not string", overlay: `{"plugins":{"x":{"policy_behavior":{"deny":[1]}}}}`, path: "/plugins/x/policy_behavior/deny/0", code: FieldCodeInvalidType}, + {name: "O5 policy_behavior not object", overlay: `{"plugins":{"x":{"policy_behavior":[]}}}`, path: "/plugins/x/policy_behavior", code: FieldCodeInvalidType}, + {name: "O5 policy_data not object", overlay: `{"plugins":{"x":{"policy_data":[1]}}}`, path: "/plugins/x/policy_data", code: FieldCodeInvalidType}, + {name: "O5 policies not array", overlay: `{"plugins":{"x":{"policies":"a"}}}`, path: "/plugins/x/policies", code: FieldCodeInvalidType}, + {name: "O5 policies item not string", overlay: `{"plugins":{"x":{"policies":[1]}}}`, path: "/plugins/x/policies/0", code: FieldCodeInvalidType}, + {name: "O5 plugin not object", overlay: `{"plugins":{"x":"ghcr.io/x/y:v1"}}`, path: "/plugins/x", code: FieldCodeInvalidType}, + {name: "O5 plugins not object", overlay: `{"plugins":[]}`, path: "/plugins", code: FieldCodeInvalidType}, + {name: "O5 source number", overlay: `{"plugins":{"x":{"source":1}}}`, path: "/plugins/x/source", code: FieldCodeInvalidType}, + // O6 + {name: "O6 plugin name upper case", overlay: `{"plugins":{"GitHub":{"source":"ghcr.io/x/y:v1"}}}`, path: "/plugins/GitHub", code: FieldCodePattern}, + {name: "O6 plugin name leading dash", overlay: `{"plugins":{"-x":{}}}`, path: "/plugins/-x", code: FieldCodePattern}, + {name: "O6 plugin name too long", overlay: `{"plugins":{"` + strings.Repeat("a", 64) + `":{}}}`, path: "/plugins/" + strings.Repeat("a", 64), code: FieldCodePattern}, + // O7 + {name: "O7 bad cron", overlay: `{"plugins":{"x":{"schedule":"every minute"}}}`, path: "/plugins/x/schedule", code: FieldCodeCron}, + {name: "O7 six-field cron", overlay: `{"plugins":{"x":{"schedule":"0 */5 * * * *"}}}`, path: "/plugins/x/schedule", code: FieldCodeCron}, + {name: "O7 empty cron", overlay: `{"plugins":{"x":{"schedule":""}}}`, path: "/plugins/x/schedule", code: FieldCodeCron}, + // O8 + {name: "O8 empty source", overlay: `{"plugins":{"x":{"source":""}}}`, path: "/plugins/x/source", code: FieldCodeSource}, + {name: "O8 blank source", overlay: `{"plugins":{"x":{"source":" "}}}`, path: "/plugins/x/source", code: FieldCodeSource}, + {name: "O8 empty policy entry", overlay: `{"plugins":{"x":{"policies":["ghcr.io/x/p:v1",""]}}}`, path: "/plugins/x/policies/1", code: FieldCodeSource}, + // O9 + {name: "O9 env in policy_data", overlay: `{"plugins":{"x":{"policy_data":{"t":"${env:X}"}}}}`, path: "/plugins/x/policy_data/t", code: FieldCodeEnvLocation}, + {name: "O9 env in nested policy_data array", overlay: `{"plugins":{"x":{"policy_data":{"a":["${env:X}"]}}}}`, path: "/plugins/x/policy_data/a/0", code: FieldCodeEnvLocation}, + {name: "O9 env in labels", overlay: `{"plugins":{"x":{"labels":{"t":"a-${env:X}"}}}}`, path: "/plugins/x/labels/t", code: FieldCodeEnvLocation}, + {name: "O9 env in source", overlay: `{"plugins":{"x":{"source":"ghcr.io/${env:ORG}/y:v1"}}}`, path: "/plugins/x/source", code: FieldCodeEnvLocation}, + {name: "O9 forbidden env", overlay: `{"plugins":{"x":{"config":{"s":"${env:CCF_API_AUTH_CLIENT_SECRET}"}}}}`, path: "/plugins/x/config/s", code: FieldCodeForbiddenEnv}, + {name: "O9 forbidden env embedded lower case", overlay: `{"plugins":{"x":{"config":{"s":"a${env:ccf_api_auth_client_id}b"}}}}`, path: "/plugins/x/config/s", code: FieldCodeForbiddenEnv}, + // O10 + {name: "O10 masked config", overlay: `{"plugins":{"x":{"config":{"password":"••••"}}}}`, path: "/plugins/x/config/password", code: FieldCodeMaskedValue}, + {name: "O10 masked policy_data", overlay: `{"plugins":{"x":{"policy_data":{"a":{"token":"••••"}}}}}`, path: "/plugins/x/policy_data/a/token", code: FieldCodeMaskedValue}, + // O11 + {name: "O11 NUL in config value", overlay: `{"plugins":{"x":{"config":{"a":"b\u0000c"}}}}`, path: "/plugins/x/config/a", code: FieldCodeInvalidValue, contains: "NUL"}, + {name: "O11 NUL in nested policy_data", overlay: `{"plugins":{"x":{"policy_data":{"a":["\u0000"]}}}}`, path: "/plugins/x/policy_data/a/0", code: FieldCodeInvalidValue, contains: "NUL"}, + {name: "O11 NUL in key", overlay: `{"plugins":{"x":{"labels":{"a\u0000":"b"}}}}`, path: "/plugins/x/labels/a\x00", code: FieldCodeInvalidValue, contains: "NUL"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fe := requireFieldError(t, ValidateOverlay(json.RawMessage(tt.overlay)), tt.path, tt.code) + if tt.contains != "" { + assert.Contains(t, fe.Message, tt.contains) + } + }) + } +} + +func TestValidateOverlayPortMessage(t *testing.T) { + err := ValidateOverlay(json.RawMessage(`{"plugins":{"x":{"config":{"port":2222}}}}`)) + errs := fieldErrors(t, err) + require.Len(t, errs, 1) + assert.Equal(t, FieldError{Path: "/plugins/x/config/port", Code: FieldCodeInvalidType, Message: "must be a string"}, errs[0]) + assert.Contains(t, err.Error(), "/plugins/x/config/port") + assert.Contains(t, err.Error(), "must be a string") +} + +func TestValidateOverlaySize(t *testing.T) { + // labelOverlay returns a valid overlay whose compact size is exactly n bytes. + labelOverlay := func(t *testing.T, prefix string, n int) string { + t.Helper() + head := `{` + prefix + `"plugins":{"x":{"labels":{"a":"` + tail := `"}}}}` + pad := n - len(head) - len(tail) + require.Positive(t, pad) + o := head + strings.Repeat("v", pad) + tail + require.Len(t, o, n) + return o + } + + t.Run("limit", func(t *testing.T) { + assert.NoError(t, ValidateOverlay(json.RawMessage(labelOverlay(t, "", MaxOverlayBytes)))) + requireFieldError(t, ValidateOverlay(json.RawMessage(labelOverlay(t, "", MaxOverlayBytes+1))), "", FieldCodeSize) + }) + t.Run("size is measured on compact JSON", func(t *testing.T) { + o := labelOverlay(t, "", MaxOverlayBytes) + pretty := strings.Replace(o, `{"plugins"`, "{\n \"plugins\"", 1) + require.Greater(t, len(pretty), MaxOverlayBytes) + assert.NoError(t, ValidateOverlay(json.RawMessage(pretty))) + }) +} + +func TestValidateOverlayErrorsSorted(t *testing.T) { + err := ValidateOverlay(json.RawMessage(`{"verbosity":9,"api":{},"plugins":{"x":{"config":{"b":1,"a":true}}}}`)) + errs := fieldErrors(t, err) + paths := make([]string, 0, len(errs)) + for _, e := range errs { + paths = append(paths, e.Path) + } + assert.Equal(t, []string{"/api", "/plugins/x/config/a", "/plugins/x/config/b", "/verbosity"}, paths) +} + +// validConfig is a complete, valid effective config. +func validConfig() Config { + return Config{ + Daemon: true, + Verbosity: 1, + API: &APIConfig{ + URL: "https://api.example.com", + Auth: &APIAuth{ClientID: "0b3c1b8a-7c8e-4d53-9a52-9a3c1d1f2e10", ClientSecret: "s3cret"}, + }, + RemoteConfig: &RemoteConfig{ + Mode: ModeApplySafe, + PollInterval: "30s", + TrustedSources: []string{"ghcr.io/compliance-framework/*"}, + OverridableConfigFlags: []string{"local-ssh:port", "*"}, + }, + AgentEvidence: &EvidenceConfig{Interval: "5m"}, + Plugins: map[string]*Plugin{ + "local-ssh": { + Source: "ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", + Schedule: strPtr("*/5 * * * *"), + ProtocolVersion: 2, + Policies: []string{"ghcr.io/compliance-framework/plugin-local-ssh-policies:v1.0.0"}, + Config: map[string]string{"host": "localhost", "password": "${env:SSH_PASSWORD}"}, + PolicyData: map[string]any{"threshold": json.Number("5")}, + }, + }, + } +} + +func TestValidateValid(t *testing.T) { + c := validConfig() + require.NoError(t, c.Validate()) + require.NoError(t, c.ValidateEditable()) + + noAuth := validConfig() + noAuth.API.Auth = nil + assert.NoError(t, noAuth.Validate(), "auth is optional") + + noRC := validConfig() + noRC.RemoteConfig = nil + assert.NoError(t, noRC.Validate(), "remote_config is optional") + + descriptor := validConfig() + descriptor.Plugins["local-ssh"].Schedule = strPtr("@hourly") + assert.NoError(t, descriptor.Validate()) + + auto := validConfig() + auto.Plugins["local-ssh"].ProtocolVersion = 0 + assert.NoError(t, auto.Validate(), "protocol_version 0 = auto in an effective config") + + empty := Config{API: &APIConfig{URL: "http://x"}} + assert.NoError(t, empty.Validate()) +} + +func TestValidateEditableErrors(t *testing.T) { + tests := []struct { + name string + mutate func(c *Config) + path string + code string + }{ + {name: "negative verbosity", mutate: func(c *Config) { c.Verbosity = -1 }, path: "/verbosity", code: FieldCodeInvalidValue}, + {name: "bad evidence interval", mutate: func(c *Config) { c.AgentEvidence.Interval = "often" }, path: "/agent_evidence/interval", code: FieldCodeDuration}, + {name: "negative evidence interval", mutate: func(c *Config) { c.AgentEvidence.Interval = "-5m" }, path: "/agent_evidence/interval", code: FieldCodeDuration}, + {name: "nil plugin", mutate: func(c *Config) { c.Plugins["other"] = nil }, path: "/plugins/other", code: FieldCodeRequired}, + {name: "empty source", mutate: func(c *Config) { c.Plugins["local-ssh"].Source = "" }, path: "/plugins/local-ssh/source", code: FieldCodeRequired}, + {name: "bad cron", mutate: func(c *Config) { c.Plugins["local-ssh"].Schedule = strPtr("nope") }, path: "/plugins/local-ssh/schedule", code: FieldCodeCron}, + {name: "six-field cron", mutate: func(c *Config) { c.Plugins["local-ssh"].Schedule = strPtr("0 */5 * * * *") }, path: "/plugins/local-ssh/schedule", code: FieldCodeCron}, + {name: "protocol_version 3", mutate: func(c *Config) { c.Plugins["local-ssh"].ProtocolVersion = 3 }, path: "/plugins/local-ssh/protocol_version", code: FieldCodeInvalidValue}, + {name: "protocol_version negative", mutate: func(c *Config) { c.Plugins["local-ssh"].ProtocolVersion = -1 }, path: "/plugins/local-ssh/protocol_version", code: FieldCodeInvalidValue}, + {name: "empty policy entry", mutate: func(c *Config) { c.Plugins["local-ssh"].Policies = append(c.Plugins["local-ssh"].Policies, "") }, path: "/plugins/local-ssh/policies/1", code: FieldCodeSource}, + {name: "env in policy_data", mutate: func(c *Config) { c.Plugins["local-ssh"].PolicyData = map[string]any{"t": "${env:X}"} }, path: "/plugins/local-ssh/policy_data/t", code: FieldCodeEnvLocation}, + {name: "env in labels", mutate: func(c *Config) { c.Plugins["local-ssh"].Labels = map[string]string{"t": "${env:X}"} }, path: "/plugins/local-ssh/labels/t", code: FieldCodeEnvLocation}, + {name: "forbidden env in config", mutate: func(c *Config) { c.Plugins["local-ssh"].Config["s"] = "${env:CCF_API_AUTH_CLIENT_SECRET}" }, path: "/plugins/local-ssh/config/s", code: FieldCodeForbiddenEnv}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := validConfig() + tt.mutate(&c) + requireFieldError(t, c.ValidateEditable(), tt.path, tt.code) + requireFieldError(t, c.Validate(), tt.path, tt.code) + }) + } +} + +func TestValidateEditableIgnoresLockedBlocks(t *testing.T) { + c := validConfig() + c.API = nil + c.RemoteConfig = &RemoteConfig{Mode: "bogus", PollInterval: "1s", TrustedSources: []string{"["}} + assert.NoError(t, c.ValidateEditable()) + assert.Error(t, c.Validate()) +} + +func TestValidateEditableRedactedBase(t *testing.T) { + c := validConfig() + c.Plugins["local-ssh"].Config["token"] = "abc" + c.Plugins["local-ssh"].Config["user"] = "root" + c.Plugins["local-ssh"].PolicyData["api_key"] = "k" + red := Redact(c, WithMaskedPointers("/plugins/local-ssh/config/user")) + require.Empty(t, red.API.Auth.ClientSecret) + require.Equal(t, MaskedValue, red.Plugins["local-ssh"].Config["user"]) + assert.NoError(t, red.ValidateEditable(), "masked values and a missing client secret are fine") + requireFieldError(t, red.Validate(), "/api/auth", FieldCodeRequired) +} + +func TestValidateAPIAndRemoteConfig(t *testing.T) { + tests := []struct { + name string + mutate func(c *Config) + path string + code string + }{ + {name: "no api", mutate: func(c *Config) { c.API = nil }, path: "/api", code: FieldCodeRequired}, + {name: "no url", mutate: func(c *Config) { c.API.URL = " " }, path: "/api/url", code: FieldCodeRequired}, + {name: "partial auth: no secret", mutate: func(c *Config) { c.API.Auth.ClientSecret = "" }, path: "/api/auth", code: FieldCodeRequired}, + {name: "partial auth: no id", mutate: func(c *Config) { c.API.Auth.ClientID = "" }, path: "/api/auth", code: FieldCodeRequired}, + {name: "client_id not a uuid", mutate: func(c *Config) { c.API.Auth.ClientID = "agent-1" }, path: "/api/auth/client_id", code: FieldCodeInvalidValue}, + {name: "bad mode", mutate: func(c *Config) { c.RemoteConfig.Mode = "apply" }, path: "/remote_config/mode", code: FieldCodeInvalidValue}, + {name: "poll_interval too small", mutate: func(c *Config) { c.RemoteConfig.PollInterval = "14s" }, path: "/remote_config/poll_interval", code: FieldCodeDuration}, + {name: "poll_interval garbage", mutate: func(c *Config) { c.RemoteConfig.PollInterval = "sometimes" }, path: "/remote_config/poll_interval", code: FieldCodeDuration}, + {name: "bad trusted source glob", mutate: func(c *Config) { c.RemoteConfig.TrustedSources = []string{"ghcr.io/*", "ghcr.io/[x"} }, path: "/remote_config/trusted_sources/1", code: FieldCodePattern}, + {name: "bad overridable key glob", mutate: func(c *Config) { c.RemoteConfig.OverridableConfigFlags = []string{"[port"} }, path: "/remote_config/overridable_config_flags/0", code: FieldCodePattern}, + {name: "bad overridable plugin glob", mutate: func(c *Config) { c.RemoteConfig.OverridableConfigFlags = []string{"port", "[x:port"} }, path: "/remote_config/overridable_config_flags/1", code: FieldCodePattern}, + {name: "bad overridable scoped key glob", mutate: func(c *Config) { c.RemoteConfig.OverridableConfigFlags = []string{"x:[port"} }, path: "/remote_config/overridable_config_flags/0", code: FieldCodePattern}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := validConfig() + tt.mutate(&c) + requireFieldError(t, c.Validate(), tt.path, tt.code) + assert.NoError(t, c.ValidateEditable()) + }) + } + + t.Run("valid remote_config variants", func(t *testing.T) { + for _, mode := range []string{"", ModeOff, ModeReport, ModeApplySafe, ModeApplyAll} { + c := validConfig() + c.RemoteConfig.Mode = mode + c.RemoteConfig.PollInterval = "15s" + assert.NoError(t, c.Validate(), mode) + } + }) +} + +func TestParseSchedule(t *testing.T) { + for _, ok := range []string{"* * * * *", "*/5 * * * *", "0 3 * * 1-5", "@hourly", "@daily", "@every 5m"} { + _, err := ParseSchedule(ok) + assert.NoError(t, err, ok) + } + for _, bad := range []string{"", "0 */5 * * * *", "* * * *", "nope", "61 * * * *"} { + _, err := ParseSchedule(bad) + assert.Error(t, err, bad) + } +} + +func TestValidationErrorsError(t *testing.T) { + assert.Equal(t, "no validation errors", ValidationErrors{}.Error()) + assert.Equal(t, "/: bad; /a: worse", ValidationErrors{{Path: "", Message: "bad"}, {Path: "/a", Message: "worse"}}.Error()) +} From c0b3792fbba1f2d1b92a140eeea31cd6ea246e35 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:09:10 -0300 Subject: [PATCH 2/2] test(agentconfig): generate a conformance golden file from the rule tables The UI re-implements MatchTrustedSource, MatchOverridableConfigFlag, KindOf/IsOCISource, PluginNamePattern, ParseSchedule and the per-field apply_safe outcome of Classify + WillApply, and tested them against a hand-copied fixture pinned to an old api commit, so a rule change failed on neither side. Hoist those test tables to package-level vars and generate testdata/conformance.json from them (in the UI fixture's shape), with every expected value computed by the real functions. TestConformanceGolden fails when the file is stale; regenerate it with go test ./pkg/agentconfig -run TestConformanceGolden -update The apply_safe cases are a new table (applySafeCases) whose field state is derived from Classify + WillApply over probe overlays. The UI fixture's drift cases are added to the Go tables (two '%' registries for KindOf, '*/5 * * * *' and '@hourly' for ParseSchedule), plus a re-enabled local plugin source case. Co-Authored-By: Claude Opus 5.5 --- pkg/agentconfig/classify_test.go | 120 ++++++ pkg/agentconfig/conformance_test.go | 163 ++++++++ pkg/agentconfig/cron_test.go | 13 +- pkg/agentconfig/remoteconfig_test.go | 98 +++-- pkg/agentconfig/sources_test.go | 52 +-- pkg/agentconfig/testdata/conformance.json | 432 ++++++++++++++++++++++ 6 files changed, 814 insertions(+), 64 deletions(-) create mode 100644 pkg/agentconfig/conformance_test.go create mode 100644 pkg/agentconfig/testdata/conformance.json diff --git a/pkg/agentconfig/classify_test.go b/pkg/agentconfig/classify_test.go index 596996b9..96dd1541 100644 --- a/pkg/agentconfig/classify_test.go +++ b/pkg/agentconfig/classify_test.go @@ -2,6 +2,9 @@ package agentconfig import ( "encoding/json" + "fmt" + "slices" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -332,3 +335,120 @@ func TestClassifyReenableKeptParts(t *testing.T) { require.NoError(t, err) assert.Equal(t, []Change{{Path: "/plugins/x/labels", Safety: Safe, Reason: ChangeReasonDataOnly}}, changes) } + +// applySafeCases predict, per field, whether one apply_safe host applies a change at path +// (Classify + WillApply): the field-level rule the UI re-implements (field-access.ts). They +// are shared with the conformance golden file (conformance_test.go). probes are concrete +// overlays that change the field; state is "editable" when the host applies every probe, +// "readonly" when it applies none and "restricted" otherwise. +var applySafeCases = []struct { + name string + trusted []string + file map[string]*Plugin + path string + probes []string + state string +}{ + { + name: "re-enable, untrusted source", + file: map[string]*Plugin{"x": {Enabled: boolPtr(false), Source: srcDisabled}}, + path: "/plugins/x/enabled", probes: []string{`{"plugins":{"x":{"enabled":true}}}`}, + state: "readonly", + }, + { + name: "re-enable, trusted source", trusted: []string{"ghcr.io/trusted/*"}, + file: map[string]*Plugin{"x": {Enabled: boolPtr(false), Source: "ghcr.io/trusted/p:v1"}}, + path: "/plugins/x/enabled", probes: []string{`{"plugins":{"x":{"enabled":true}}}`}, + state: "editable", + }, + { + name: "re-enable keeps untrusted and local policies (TestClassifyReenableKeptParts)", trusted: []string{"ghcr.io/trusted/*"}, + file: map[string]*Plugin{"x": {Enabled: boolPtr(false), Source: "ghcr.io/trusted/p:v1", Policies: []string{"ghcr.io/evil/pol:v9", "/tmp/local-policy"}}}, + path: "/plugins/x/enabled", probes: []string{`{"plugins":{"x":{"enabled":true}}}`}, + state: "readonly", + }, + { + name: "re-enable keeps ${env:} references (TestClassifyReenableKeptParts)", trusted: []string{"ghcr.io/trusted/*"}, + file: map[string]*Plugin{"x": {Enabled: boolPtr(false), Source: "ghcr.io/trusted/p:v1", Config: map[string]string{"token": "${env:DB_TOKEN}"}}}, + path: "/plugins/x/enabled", probes: []string{`{"plugins":{"x":{"enabled":true}}}`}, + state: "readonly", + }, + { + name: "re-enable keeps a local plugin source (fp 2db275ed2d26)", trusted: []string{"ghcr.io/trusted/*"}, + file: map[string]*Plugin{"x": {Enabled: boolPtr(false), Source: "./bin/local-plugin"}}, + path: "/plugins/x/enabled", probes: []string{`{"plugins":{"x":{"enabled":true}}}`}, + state: "readonly", + }, + { + name: "disabling is data-only", + file: map[string]*Plugin{"x": {Source: "ghcr.io/other/p:v1"}}, + path: "/plugins/x/enabled", probes: []string{`{"plugins":{"x":{"enabled":false}}}`}, + state: "editable", + }, + { + name: "a new source needs trusted_sources, but reusing one is already-used", + file: map[string]*Plugin{"x": {Source: "ghcr.io/a/x:v1"}, "y": {Source: "ghcr.io/a/y:v1"}}, + path: "/plugins/x/source", + probes: []string{ + `{"plugins":{"x":{"source":"ghcr.io/a/y:v1"}}}`, + `{"plugins":{"x":{"source":"ghcr.io/a/new:v1"}}}`, + }, + state: "restricted", + }, + { + name: "a disabled plugin's sources are not already used", + file: map[string]*Plugin{"x": {Enabled: boolPtr(false), Source: "ghcr.io/a/x:v1"}}, + path: "/plugins/x/source", probes: []string{`{"plugins":{"x":{"source":"ghcr.io/a/new:v1"}}}`}, + state: "readonly", + }, + { + name: "config key needs an overridable_config_flags entry", + file: map[string]*Plugin{"local-ssh": {Source: "s"}}, + path: "/plugins/local-ssh/config/host", probes: []string{`{"plugins":{"local-ssh":{"config":{"host":"h"}}}}`}, + state: "readonly", + }, + { + name: "data-only fields", + file: map[string]*Plugin{"local-ssh": {Source: "s"}}, + path: "/plugins/local-ssh/policy_data/threshold", probes: []string{`{"plugins":{"local-ssh":{"policy_data":{"threshold":5}}}}`}, + state: "editable", + }, +} + +// applySafeState classifies every probe of a case on an apply_safe host trusting trusted +// and returns the field state (see applySafeCases). It fails when a probe does not change +// the field at path. +func applySafeState(trusted []string, file map[string]*Plugin, path string, probes []string) (string, error) { + rc := RemoteConfig{Mode: ModeApplySafe, TrustedSources: trusted}.Normalize(true) + applied := 0 + for _, probe := range probes { + changes, err := Classify(Config{Plugins: file}, json.RawMessage(probe), rc) + if err != nil { + return "", err + } + if !slices.ContainsFunc(changes, func(c Change) bool { return c.Path == path || strings.HasPrefix(path, c.Path+"/") }) { + return "", fmt.Errorf("probe %s does not change %s: %v", probe, path, changes) + } + if ok, _ := WillApply(rc, changes); ok { + applied++ + } + } + switch applied { + case len(probes): + return "editable", nil + case 0: + return "readonly", nil + default: + return "restricted", nil + } +} + +func TestClassifyApplySafeFields(t *testing.T) { + for _, tt := range applySafeCases { + t.Run(tt.name, func(t *testing.T) { + got, err := applySafeState(tt.trusted, tt.file, tt.path, tt.probes) + require.NoError(t, err) + assert.Equal(t, tt.state, got) + }) + } +} diff --git a/pkg/agentconfig/conformance_test.go b/pkg/agentconfig/conformance_test.go new file mode 100644 index 00000000..aaf11ca7 --- /dev/null +++ b/pkg/agentconfig/conformance_test.go @@ -0,0 +1,163 @@ +package agentconfig + +import ( + "bytes" + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// conformanceGolden is the conformance file for the clients that re-implement some of this +// package's rules (the UI: glob.ts, cron5.ts, field-access.ts, validation.ts). It is +// generated from the same tables the unit tests use, with every expected value computed by +// the real functions, and TestConformanceGolden fails when it is stale. +const conformanceGolden = "testdata/conformance.json" + +var updateConformance = flag.Bool("update", false, "rewrite "+conformanceGolden+" (TestConformanceGolden)") + +type conformanceDoc struct { + Comment string `json:"_comment"` + Source string `json:"_source"` + TrustedSources struct { + Patterns []string `json:"patterns"` + Cases [][2]any `json:"cases"` + Extra []conformanceTrustedSource `json:"extra"` + } `json:"trustedSources"` + OverridableConfigFlags []conformanceConfigFlag `json:"overridableConfigFlags"` + SourceKinds [][2]string `json:"sourceKinds"` + Schedules conformanceValidity `json:"schedules"` + ApplySafe struct { + Comment string `json:"_comment"` + Cases []conformanceApplySafe `json:"cases"` + } `json:"applySafe"` + PluginNames conformanceValidity `json:"pluginNames"` +} + +type conformanceTrustedSource struct { + Patterns []string `json:"patterns"` + Source string `json:"source"` + Want bool `json:"want"` +} + +type conformanceConfigFlag struct { + Name string `json:"name"` + Flags []string `json:"flags"` + Plugin string `json:"plugin"` + Key string `json:"key"` + Want bool `json:"want"` +} + +type conformanceValidity struct { + Valid []string `json:"valid"` + Invalid []string `json:"invalid"` +} + +type conformanceApplySafe struct { + Name string `json:"name"` + Trusted []string `json:"trusted"` + File map[string]*Plugin `json:"file"` + Overlay json.RawMessage `json:"overlay"` // the UI's draft overlay: always null here + Path string `json:"path"` + State string `json:"state"` +} + +func nonNilStrings(s []string) []string { + if s == nil { + return []string{} + } + return s +} + +// conformanceDocument builds the golden file from the unit-test tables, computing every +// expected value with the real functions. +func conformanceDocument() ([]byte, error) { + var doc conformanceDoc + doc.Comment = "Expected results of the pkg/agentconfig rules that clients re-implement (the UI's glob.ts, cron5.ts, field-access.ts, validation.ts). " + + "Generated from the API's own test tables (remoteconfig_test.go, sources_test.go incl. TestNamePatterns, cron_test.go, classify_test.go), " + + "every expected value computed by the real functions. Do not edit: regenerate with go test ./pkg/agentconfig -run TestConformanceGolden -update." + doc.Source = "compliance-framework/api pkg/agentconfig/testdata/conformance.json" + + doc.TrustedSources.Patterns = trustedSourcePatterns + rc := RemoteConfig{TrustedSources: trustedSourcePatterns} + for _, c := range trustedSourceCases { + doc.TrustedSources.Cases = append(doc.TrustedSources.Cases, [2]any{c.source, MatchTrustedSource(rc, c.source)}) + } + for _, c := range trustedSourceExtraCases { + doc.TrustedSources.Extra = append(doc.TrustedSources.Extra, conformanceTrustedSource{ + Patterns: nonNilStrings(c.patterns), + Source: c.source, + Want: MatchTrustedSource(RemoteConfig{TrustedSources: c.patterns}, c.source), + }) + } + + for _, c := range overridableConfigFlagCases { + doc.OverridableConfigFlags = append(doc.OverridableConfigFlags, conformanceConfigFlag{ + Name: c.name, Flags: nonNilStrings(c.flags), Plugin: c.plugin, Key: c.key, + Want: MatchOverridableConfigFlag(RemoteConfig{OverridableConfigFlags: c.flags}, c.plugin, c.key), + }) + } + + for _, c := range sourceKindCases { + doc.SourceKinds = append(doc.SourceKinds, [2]string{c.source, string(KindOf(c.source))}) + } + + doc.Schedules = conformanceValidity{Valid: []string{}, Invalid: []string{}} + for _, expr := range append(append([]string{}, schedulesValid...), schedulesInvalid...) { + if _, err := ParseSchedule(expr); err == nil { + doc.Schedules.Valid = append(doc.Schedules.Valid, expr) + } else { + doc.Schedules.Invalid = append(doc.Schedules.Invalid, expr) + } + } + + doc.PluginNames = conformanceValidity{Valid: []string{}, Invalid: []string{}} + for _, name := range append(append([]string{}, pluginNamesValid...), pluginNamesInvalid...) { + if PluginNamePattern.MatchString(name) { + doc.PluginNames.Valid = append(doc.PluginNames.Valid, name) + } else { + doc.PluginNames.Invalid = append(doc.PluginNames.Invalid, name) + } + } + + doc.ApplySafe.Comment = "classify_test.go applySafeCases: whether an apply_safe host applies a change at `path` (Classify + WillApply over the case's probe overlays), for the field-level prediction of field-access.ts. `state` is fieldAccess over that one host: editable (every probe applies), restricted (some do), readonly (none do)." + for _, c := range applySafeCases { + state, err := applySafeState(c.trusted, c.file, c.path, c.probes) + if err != nil { + return nil, err + } + doc.ApplySafe.Cases = append(doc.ApplySafe.Cases, conformanceApplySafe{ + Name: c.name, Trusted: nonNilStrings(c.trusted), File: c.file, + Overlay: json.RawMessage("null"), Path: c.path, State: state, + }) + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(doc); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// TestConformanceGolden fails when testdata/conformance.json no longer matches the rules. +// Regenerate it with: go test ./pkg/agentconfig -run TestConformanceGolden -update +func TestConformanceGolden(t *testing.T) { + got, err := conformanceDocument() + require.NoError(t, err) + if *updateConformance { + require.NoError(t, os.MkdirAll(filepath.Dir(conformanceGolden), 0o755)) + require.NoError(t, os.WriteFile(conformanceGolden, got, 0o644)) + return + } + want, err := os.ReadFile(conformanceGolden) + require.NoError(t, err, "run: go test ./pkg/agentconfig -run TestConformanceGolden -update") + assert.Equal(t, string(want), string(got), + "%s is stale: a rule or its test table changed. Regenerate it with go test ./pkg/agentconfig -run TestConformanceGolden -update, and update the clients that consume it", conformanceGolden) +} diff --git a/pkg/agentconfig/cron_test.go b/pkg/agentconfig/cron_test.go index 9ec564ca..b200a132 100644 --- a/pkg/agentconfig/cron_test.go +++ b/pkg/agentconfig/cron_test.go @@ -7,13 +7,20 @@ import ( "github.com/stretchr/testify/require" ) +// schedulesValid and schedulesInvalid are shared with the conformance golden file +// (conformance_test.go). robfig/cron panics on the invalid time-zone prefixes; ParseSchedule +// must return an error instead. +var ( + schedulesValid = []string{"TZ=UTC 0 * * * *", "CRON_TZ=Europe/London 0 * * * *", "*/5 * * * *", "@hourly"} + schedulesInvalid = []string{"TZ=UTC", "CRON_TZ=UTC", "TZ=", "CRON_TZ="} +) + func TestParseScheduleTimeZonePrefix(t *testing.T) { - for _, expr := range []string{"TZ=UTC 0 * * * *", "CRON_TZ=Europe/London 0 * * * *"} { + for _, expr := range schedulesValid { _, err := ParseSchedule(expr) assert.NoError(t, err, expr) } - // robfig/cron panics on these; ParseSchedule must return an error instead. - for _, expr := range []string{"TZ=UTC", "CRON_TZ=UTC", "TZ=", "CRON_TZ="} { + for _, expr := range schedulesInvalid { require.NotPanics(t, func() { _, err := ParseSchedule(expr) assert.Error(t, err, expr) diff --git a/pkg/agentconfig/remoteconfig_test.go b/pkg/agentconfig/remoteconfig_test.go index 6c20d2d1..6b748f0a 100644 --- a/pkg/agentconfig/remoteconfig_test.go +++ b/pkg/agentconfig/remoteconfig_test.go @@ -108,52 +108,70 @@ func TestPluginIsEnabled(t *testing.T) { assert.False(t, (&Plugin{Enabled: boolPtr(false)}).IsEnabled()) } +// trustedSourcePatterns, trustedSourceCases and trustedSourceExtraCases are the +// MatchTrustedSource table, shared with the conformance golden file (conformance_test.go). +var trustedSourcePatterns = []string{"ghcr.io/compliance-framework/*", "docker.io/acme/plugin-?:v1", "[bad"} + +var trustedSourceCases = []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}, +} + +var trustedSourceExtraCases = []struct { + patterns []string + source string + want bool +}{ + {patterns: nil, source: "ghcr.io/x/y:v1", want: false}, // default [] trusts nothing + {patterns: []string{"*/*/*"}, source: "ghcr.io/x/y:v1", want: true}, +} + 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 { + rc := RemoteConfig{TrustedSources: trustedSourcePatterns} + for _, tt := range trustedSourceCases { 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")) + for _, tt := range trustedSourceExtraCases { + assert.Equal(t, tt.want, MatchTrustedSource(RemoteConfig{TrustedSources: tt.patterns}, tt.source), "%v %s", tt.patterns, tt.source) + } +} + +// overridableConfigFlagCases is the MatchOverridableConfigFlag table, shared with the +// conformance golden file (conformance_test.go). +var overridableConfigFlagCases = []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}, } 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 { + for _, tt := range overridableConfigFlagCases { 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/sources_test.go b/pkg/agentconfig/sources_test.go index 9d2843cb..25617819 100644 --- a/pkg/agentconfig/sources_test.go +++ b/pkg/agentconfig/sources_test.go @@ -6,26 +6,36 @@ import ( "github.com/stretchr/testify/assert" ) +// sourceKindCases, pluginNamesValid and pluginNamesInvalid are shared with the conformance +// golden file (conformance_test.go). +var sourceKindCases = []struct { + source string + kind SourceKind +}{ + {"ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", SourceKindOCI}, + {"ghcr.io/compliance-framework/plugin-local-ssh-policies:latest", SourceKindOCI}, + {"docker.io/library/alpine:3.20", SourceKindOCI}, + {"localhost:5000/plugin:v1", SourceKindOCI}, + {"registry.example.com:5000/a/b/c:1.2.3", SourceKindOCI}, + {"ghcr.io/x/y", SourceKindLocal}, // strict validation requires an explicit tag + {"ghcr.io/X/Y:v1", SourceKindLocal}, + {"ghcr.io/x/y:", SourceKindLocal}, + {"./plugins/foo", SourceKindLocal}, + {"/opt/plugin", SourceKindLocal}, + {"plugin", SourceKindLocal}, + {"", SourceKindLocal}, + {"inline:ssh", SourceKindLocal}, // no special meaning: a local path + {"foo%.com/acme/plugin:v1", SourceKindLocal}, // a '%' registry does not parse back unchanged + {"foo%41.com/acme/plugin:v1", SourceKindLocal}, // nor does a percent-escape +} + +var ( + pluginNamesValid = []string{"a", "0", "local-ssh", "ssh_tuned", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk"} + pluginNamesInvalid = []string{"", "GitHub", "Ssh.Tuned", "-a", "_a", "a.b", "a b", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl"} +) + func TestKindOfAndIsOCISource(t *testing.T) { - tests := []struct { - source string - kind SourceKind - }{ - {"ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", SourceKindOCI}, - {"ghcr.io/compliance-framework/plugin-local-ssh-policies:latest", SourceKindOCI}, - {"docker.io/library/alpine:3.20", SourceKindOCI}, - {"localhost:5000/plugin:v1", SourceKindOCI}, - {"registry.example.com:5000/a/b/c:1.2.3", SourceKindOCI}, - {"ghcr.io/x/y", SourceKindLocal}, // strict validation requires an explicit tag - {"ghcr.io/X/Y:v1", SourceKindLocal}, - {"ghcr.io/x/y:", SourceKindLocal}, - {"./plugins/foo", SourceKindLocal}, - {"/opt/plugin", SourceKindLocal}, - {"plugin", SourceKindLocal}, - {"", SourceKindLocal}, - {"inline:ssh", SourceKindLocal}, // no special meaning: a local path - } - for _, tt := range tests { + for _, tt := range sourceKindCases { t.Run(tt.source, func(t *testing.T) { assert.Equal(t, tt.kind, KindOf(tt.source)) assert.Equal(t, tt.kind == SourceKindOCI, IsOCISource(tt.source)) @@ -34,10 +44,10 @@ func TestKindOfAndIsOCISource(t *testing.T) { } func TestNamePatterns(t *testing.T) { - for _, ok := range []string{"a", "0", "local-ssh", "ssh_tuned", "a" + string(make([]byte, 0)), "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk"} { + for _, ok := range pluginNamesValid { assert.True(t, PluginNamePattern.MatchString(ok), ok) } - for _, bad := range []string{"", "GitHub", "Ssh.Tuned", "-a", "_a", "a.b", "a b", "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl"} { + for _, bad := range pluginNamesInvalid { assert.False(t, PluginNamePattern.MatchString(bad), bad) } } diff --git a/pkg/agentconfig/testdata/conformance.json b/pkg/agentconfig/testdata/conformance.json new file mode 100644 index 00000000..98fe59d7 --- /dev/null +++ b/pkg/agentconfig/testdata/conformance.json @@ -0,0 +1,432 @@ +{ + "_comment": "Expected results of the pkg/agentconfig rules that clients re-implement (the UI's glob.ts, cron5.ts, field-access.ts, validation.ts). Generated from the API's own test tables (remoteconfig_test.go, sources_test.go incl. TestNamePatterns, cron_test.go, classify_test.go), every expected value computed by the real functions. Do not edit: regenerate with go test ./pkg/agentconfig -run TestConformanceGolden -update.", + "_source": "compliance-framework/api pkg/agentconfig/testdata/conformance.json", + "trustedSources": { + "patterns": [ + "ghcr.io/compliance-framework/*", + "docker.io/acme/plugin-?:v1", + "[bad" + ], + "cases": [ + [ + "ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", + true + ], + [ + "ghcr.io/compliance-framework/sub/plugin:v1", + false + ], + [ + "ghcr.io/Compliance-Framework/plugin:v1", + false + ], + [ + "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 + ] + ], + "extra": [ + { + "patterns": [], + "source": "ghcr.io/x/y:v1", + "want": false + }, + { + "patterns": [ + "*/*/*" + ], + "source": "ghcr.io/x/y:v1", + "want": true + } + ] + }, + "overridableConfigFlags": [ + { + "name": "default empty", + "flags": [], + "plugin": "local-ssh", + "key": "port", + "want": false + }, + { + "name": "star", + "flags": [ + "*" + ], + "plugin": "local-ssh", + "key": "port", + "want": true + }, + { + "name": "star any plugin", + "flags": [ + "*" + ], + "plugin": "other", + "key": "anything", + "want": true + }, + { + "name": "scoped match", + "flags": [ + "local-ssh:port" + ], + "plugin": "local-ssh", + "key": "port", + "want": true + }, + { + "name": "scoped other key", + "flags": [ + "local-ssh:port" + ], + "plugin": "local-ssh", + "key": "host", + "want": false + }, + { + "name": "scoped other plugin", + "flags": [ + "local-ssh:port" + ], + "plugin": "remote-ssh", + "key": "port", + "want": false + }, + { + "name": "unscoped key any plugin", + "flags": [ + "port" + ], + "plugin": "remote-ssh", + "key": "port", + "want": true + }, + { + "name": "plugin glob", + "flags": [ + "*-ssh:port" + ], + "plugin": "remote-ssh", + "key": "port", + "want": true + }, + { + "name": "key glob", + "flags": [ + "local-ssh:tls_*" + ], + "plugin": "local-ssh", + "key": "tls_verify", + "want": true + }, + { + "name": "case-sensitive", + "flags": [ + "local-ssh:Port" + ], + "plugin": "local-ssh", + "key": "port", + "want": false + }, + { + "name": "split at first colon", + "flags": [ + "p*:a:b" + ], + "plugin": "p1", + "key": "a:b", + "want": true + }, + { + "name": "split at first colon, plugin side", + "flags": [ + "p*:a:b" + ], + "plugin": "p1:a", + "key": "b", + "want": false + }, + { + "name": "bad glob skipped", + "flags": [ + "[x:port", + "local-ssh:[", + "local-ssh:port" + ], + "plugin": "local-ssh", + "key": "port", + "want": true + }, + { + "name": "only bad globs", + "flags": [ + "[x:port", + "local-ssh:[" + ], + "plugin": "local-ssh", + "key": "port", + "want": false + } + ], + "sourceKinds": [ + [ + "ghcr.io/compliance-framework/plugin-local-ssh:v1.0.0", + "oci" + ], + [ + "ghcr.io/compliance-framework/plugin-local-ssh-policies:latest", + "oci" + ], + [ + "docker.io/library/alpine:3.20", + "oci" + ], + [ + "localhost:5000/plugin:v1", + "oci" + ], + [ + "registry.example.com:5000/a/b/c:1.2.3", + "oci" + ], + [ + "ghcr.io/x/y", + "local" + ], + [ + "ghcr.io/X/Y:v1", + "local" + ], + [ + "ghcr.io/x/y:", + "local" + ], + [ + "./plugins/foo", + "local" + ], + [ + "/opt/plugin", + "local" + ], + [ + "plugin", + "local" + ], + [ + "", + "local" + ], + [ + "inline:ssh", + "local" + ], + [ + "foo%.com/acme/plugin:v1", + "local" + ], + [ + "foo%41.com/acme/plugin:v1", + "local" + ] + ], + "schedules": { + "valid": [ + "TZ=UTC 0 * * * *", + "CRON_TZ=Europe/London 0 * * * *", + "*/5 * * * *", + "@hourly" + ], + "invalid": [ + "TZ=UTC", + "CRON_TZ=UTC", + "TZ=", + "CRON_TZ=" + ] + }, + "applySafe": { + "_comment": "classify_test.go applySafeCases: whether an apply_safe host applies a change at `path` (Classify + WillApply over the case's probe overlays), for the field-level prediction of field-access.ts. `state` is fieldAccess over that one host: editable (every probe applies), restricted (some do), readonly (none do).", + "cases": [ + { + "name": "re-enable, untrusted source", + "trusted": [], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/other/plugin-disabled:v1" + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "re-enable, trusted source", + "trusted": [ + "ghcr.io/trusted/*" + ], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/trusted/p:v1" + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "editable" + }, + { + "name": "re-enable keeps untrusted and local policies (TestClassifyReenableKeptParts)", + "trusted": [ + "ghcr.io/trusted/*" + ], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/trusted/p:v1", + "policies": [ + "ghcr.io/evil/pol:v9", + "/tmp/local-policy" + ] + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "re-enable keeps ${env:} references (TestClassifyReenableKeptParts)", + "trusted": [ + "ghcr.io/trusted/*" + ], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/trusted/p:v1", + "config": { + "token": "${env:DB_TOKEN}" + } + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "re-enable keeps a local plugin source (fp 2db275ed2d26)", + "trusted": [ + "ghcr.io/trusted/*" + ], + "file": { + "x": { + "enabled": false, + "source": "./bin/local-plugin" + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "readonly" + }, + { + "name": "disabling is data-only", + "trusted": [], + "file": { + "x": { + "source": "ghcr.io/other/p:v1" + } + }, + "overlay": null, + "path": "/plugins/x/enabled", + "state": "editable" + }, + { + "name": "a new source needs trusted_sources, but reusing one is already-used", + "trusted": [], + "file": { + "x": { + "source": "ghcr.io/a/x:v1" + }, + "y": { + "source": "ghcr.io/a/y:v1" + } + }, + "overlay": null, + "path": "/plugins/x/source", + "state": "restricted" + }, + { + "name": "a disabled plugin's sources are not already used", + "trusted": [], + "file": { + "x": { + "enabled": false, + "source": "ghcr.io/a/x:v1" + } + }, + "overlay": null, + "path": "/plugins/x/source", + "state": "readonly" + }, + { + "name": "config key needs an overridable_config_flags entry", + "trusted": [], + "file": { + "local-ssh": { + "source": "s" + } + }, + "overlay": null, + "path": "/plugins/local-ssh/config/host", + "state": "readonly" + }, + { + "name": "data-only fields", + "trusted": [], + "file": { + "local-ssh": { + "source": "s" + } + }, + "overlay": null, + "path": "/plugins/local-ssh/policy_data/threshold", + "state": "editable" + } + ] + }, + "pluginNames": { + "valid": [ + "a", + "0", + "local-ssh", + "ssh_tuned", + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk" + ], + "invalid": [ + "", + "GitHub", + "Ssh.Tuned", + "-a", + "_a", + "a.b", + "a b", + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl" + ] + } +}