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 new file mode 100644 index 00000000..b200a132 --- /dev/null +++ b/pkg/agentconfig/cron_test.go @@ -0,0 +1,32 @@ +package agentconfig + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "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 schedulesValid { + _, err := ParseSchedule(expr) + assert.NoError(t, err, expr) + } + for _, expr := range schedulesInvalid { + 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/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" + ] + } +} 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/