diff --git a/README.md b/README.md index f01db2059..4e47d2482 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,21 @@ diki report generate diff \ difference1.json difference2.json ``` +### Config Merge + +Diki can merge two configuration files, a base (default) config and a custom config, into a single config. +This is helpful when you want to reuse the same rule options across different diki runs. +Only `ruleOptions` are merged, everything else (provider args, metadata, output) comes from the custom config. + +If either config skips a rule, the merged result will always skip it. + +```bash +diki config merge \ + --base=base.yaml \ + --custom=custom.yaml \ + --output=merged.yaml +``` + ### Unit Tests You can manually run the tests via `make test`. diff --git a/cmd/diki/app/app.go b/cmd/diki/app/app.go index b4bfc6b10..acb39b5ee 100644 --- a/cmd/diki/app/app.go +++ b/cmd/diki/app/app.go @@ -24,6 +24,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "github.com/gardener/diki/pkg/config" + "github.com/gardener/diki/pkg/config/merge" "github.com/gardener/diki/pkg/metadata" "github.com/gardener/diki/pkg/provider" "github.com/gardener/diki/pkg/report" @@ -170,6 +171,37 @@ e.g. to check compliance of your hyperscaler accounts.`, showCmd.AddCommand(showProviderCmd) + mergeRegistryFuncs := []provider.MergeRegistryFunc{} + for _, providerOption := range providerOptions { + if providerOption.MergeRegistryFunc != nil { + mergeRegistryFuncs = append(mergeRegistryFuncs, providerOption.MergeRegistryFunc) + } + } + + configCmd := &cobra.Command{ + Use: "config", + Short: "Config is the root command for configuration operations.", + Long: "Config is the root command for configuration operations.", + RunE: func(_ *cobra.Command, _ []string) error { + return errors.New("config subcommand not selected") + }, + } + + rootCmd.AddCommand(configCmd) + + var configMergeOpts configMergeOptions + configMergeCmd := &cobra.Command{ + Use: "merge", + Short: "Merge a base config with a custom config.", + Long: "Merge combines rule options from a base (default) config with a custom config. The custom config is primary; only ruleOptions are merged.", + RunE: func(_ *cobra.Command, _ []string) error { + return configMergeCmd(configMergeOpts, mergeRegistryFuncs, logger) + }, + } + + addConfigMergeFlags(configMergeCmd, &configMergeOpts) + configCmd.AddCommand(configMergeCmd) + return rootCmd } @@ -557,6 +589,65 @@ type diffOptions struct { title string } +type configMergeOptions struct { + basePath string + customPath string + outputPath string +} + +func addConfigMergeFlags(cmd *cobra.Command, opts *configMergeOptions) { + cmd.PersistentFlags().StringVar(&opts.basePath, "base", "", "Path to the base (default) configuration file.") + cmd.PersistentFlags().StringVar(&opts.customPath, "custom", "", "Path to the custom configuration file.") + cmd.PersistentFlags().StringVar(&opts.outputPath, "output", "", "Output path for the merged configuration. If not set, output is written to stdout.") + _ = cmd.MarkPersistentFlagRequired("base") + _ = cmd.MarkPersistentFlagRequired("custom") +} + +func configMergeCmd(opts configMergeOptions, registryFuncs []provider.MergeRegistryFunc, logger *slog.Logger) error { + + baseConfig, err := readConfig(opts.basePath) + if err != nil { + return fmt.Errorf("failed to read base config: %w", err) + } + + customConfig, err := readConfig(opts.customPath) + if err != nil { + return fmt.Errorf("failed to read custom config: %w", err) + } + + registry := merge.NewRegistry() + for _, fn := range registryFuncs { + fn(registry) + } + + merged, err := merge.MergeConfigs(baseConfig, customConfig, registry) + if err != nil { + return fmt.Errorf("failed to merge configs: %w", err) + } + + data, err := yaml.Marshal(merged) + if err != nil { + return fmt.Errorf("failed to marshal merged config: %w", err) + } + + var writer io.Writer = os.Stdout + if len(opts.outputPath) > 0 { + file, err := os.OpenFile(opts.outputPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer func() { + if err := file.Close(); err != nil { + logger.Error(err.Error()) + } + }() + writer = file + } + + _, err = writer.Write(data) + return err +} + func readConfig(filePath string) (*config.DikiConfig, error) { data, err := os.ReadFile(filepath.Clean(filePath)) if err != nil { diff --git a/cmd/diki/main.go b/cmd/diki/main.go index 222f91f2f..a09a8f0a9 100644 --- a/cmd/diki/main.go +++ b/cmd/diki/main.go @@ -10,21 +10,27 @@ import ( controllerruntime "sigs.k8s.io/controller-runtime" "github.com/gardener/diki/cmd/diki/app" + "github.com/gardener/diki/pkg/config/merge" "github.com/gardener/diki/pkg/provider" "github.com/gardener/diki/pkg/provider/builder" "github.com/gardener/diki/pkg/provider/garden" + gardenmerge "github.com/gardener/diki/pkg/provider/garden/ruleset/securityhardenedshoot" "github.com/gardener/diki/pkg/provider/gardener" + gardenermerge "github.com/gardener/diki/pkg/provider/gardener/ruleset/disak8sstig" "github.com/gardener/diki/pkg/provider/managedk8s" + managedk8sdisa "github.com/gardener/diki/pkg/provider/managedk8s/ruleset/disak8sstig" + "github.com/gardener/diki/pkg/provider/managedk8s/ruleset/securityhardenedk8s" "github.com/gardener/diki/pkg/provider/virtualgarden" + virtualgardenmerge "github.com/gardener/diki/pkg/provider/virtualgarden/ruleset/disak8sstig" ) func main() { cmd := app.NewDikiCommand( map[string]provider.ProviderOption{ - garden.ProviderID: {ProviderFromConfigFunc: builder.GardenProviderFromConfig, MetadataFunc: builder.GardenProviderMetadata}, - gardener.ProviderID: {ProviderFromConfigFunc: builder.GardenerProviderFromConfig, MetadataFunc: builder.GardenerProviderMetadata}, - managedk8s.ProviderID: {ProviderFromConfigFunc: builder.ManagedK8SProviderFromConfig, MetadataFunc: builder.ManagedK8SProviderMetadata, DefaultDikiConfigFunc: managedk8s.ManagedK8sDefaultDikiConfigFunc}, - virtualgarden.ProviderID: {ProviderFromConfigFunc: builder.VirtualGardenProviderFromConfig, MetadataFunc: builder.VirtualGardenProviderMetadata}, + garden.ProviderID: {ProviderFromConfigFunc: builder.GardenProviderFromConfig, MetadataFunc: builder.GardenProviderMetadata, MergeRegistryFunc: gardenmerge.RegisterMergeFuncs}, + gardener.ProviderID: {ProviderFromConfigFunc: builder.GardenerProviderFromConfig, MetadataFunc: builder.GardenerProviderMetadata, MergeRegistryFunc: gardenermerge.RegisterMergeFuncs}, + managedk8s.ProviderID: {ProviderFromConfigFunc: builder.ManagedK8SProviderFromConfig, MetadataFunc: builder.ManagedK8SProviderMetadata, DefaultDikiConfigFunc: managedk8s.ManagedK8sDefaultDikiConfigFunc, MergeRegistryFunc: managedk8sMergeRegistryFunc}, + virtualgarden.ProviderID: {ProviderFromConfigFunc: builder.VirtualGardenProviderFromConfig, MetadataFunc: builder.VirtualGardenProviderMetadata, MergeRegistryFunc: virtualgardenmerge.RegisterMergeFuncs}, }, ) @@ -32,3 +38,8 @@ func main() { log.Fatal(err) } } + +func managedk8sMergeRegistryFunc(r *merge.Registry) { + managedk8sdisa.RegisterMergeFuncs(r) + securityhardenedk8s.RegisterMergeFuncs(r) +} diff --git a/pkg/config/merge/merge.go b/pkg/config/merge/merge.go new file mode 100644 index 000000000..20eecfcba --- /dev/null +++ b/pkg/config/merge/merge.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package merge + +import ( + "github.com/gardener/diki/pkg/config" +) + +// MergeConfigs merges two DikiConfigs. The custom config is the primary config — its structure, +// providers, metadata, output, and provider/ruleset args are preserved as-is. Only ruleOptions +// within matching rulesets (matched by providerID + rulesetID + version) are merged. +// +// Merge rules for ruleOptions: +// - Rule in custom only: kept as-is +// - Rule in base only: appended to the output +// - Rule in both + MergeableOption: Merge() is called (base.Merge(custom)) +// - Rule in both + not MergeableOption: custom args win +// - Skip always wins: if either config skips a rule, the merged result skips it +func MergeConfigs(base, custom *config.DikiConfig, registry *Registry) (*config.DikiConfig, error) { + if base == nil { + return custom, nil + } + if custom == nil { + return custom, nil + } + + baseProviders := indexProviders(base.Providers) + + for pi := range custom.Providers { + customProvider := &custom.Providers[pi] + baseProvider, found := baseProviders[customProvider.ID] + if !found { + continue + } + + baseRulesets := indexRulesets(baseProvider.Rulesets) + + for ri := range customProvider.Rulesets { + customRuleset := &customProvider.Rulesets[ri] + rulesetKey := rulesetIndexKey{ID: customRuleset.ID, Version: customRuleset.Version} + baseRuleset, found := baseRulesets[rulesetKey] + if !found { + continue + } + + merged, err := mergeRuleOptions( + baseRuleset.RuleOptions, + customRuleset.RuleOptions, + customProvider.ID, + customRuleset.ID, + customRuleset.Version, + registry, + ) + if err != nil { + return nil, err + } + customRuleset.RuleOptions = merged + } + } + + return custom, nil +} + +func mergeRuleOptions( + baseOpts, customOpts []config.RuleOptionsConfig, + providerID, rulesetID, version string, + registry *Registry, +) ([]config.RuleOptionsConfig, error) { + baseByRuleID := make(map[string]config.RuleOptionsConfig, len(baseOpts)) + for _, opt := range baseOpts { + baseByRuleID[opt.RuleID] = opt + } + + customRuleIDs := make(map[string]struct{}, len(customOpts)) + merged := make([]config.RuleOptionsConfig, 0, len(customOpts)+len(baseOpts)) + + for _, customOpt := range customOpts { + customRuleIDs[customOpt.RuleID] = struct{}{} + + baseOpt, inBase := baseByRuleID[customOpt.RuleID] + if !inBase { + merged = append(merged, customOpt) + continue + } + + mergedOpt, err := mergeSingleRuleOption(baseOpt, customOpt, providerID, rulesetID, version, registry) + if err != nil { + return nil, err + } + merged = append(merged, mergedOpt) + } + + for _, baseOpt := range baseOpts { + if _, inCustom := customRuleIDs[baseOpt.RuleID]; !inCustom { + merged = append(merged, baseOpt) + } + } + + return merged, nil +} + +func mergeSingleRuleOption( + baseOpt, customOpt config.RuleOptionsConfig, + providerID, rulesetID, version string, + registry *Registry, +) (config.RuleOptionsConfig, error) { + result := config.RuleOptionsConfig{ + RuleID: customOpt.RuleID, + } + + result.Skip = mergeSkip(baseOpt.Skip, customOpt.Skip) + + if baseOpt.Args == nil && customOpt.Args == nil { + return result, nil + } + if baseOpt.Args == nil { + result.Args = customOpt.Args + return result, nil + } + if customOpt.Args == nil { + result.Args = baseOpt.Args + return result, nil + } + + key := RegistryKey{ + ProviderID: providerID, + RulesetID: rulesetID, + Version: version, + RuleID: customOpt.RuleID, + } + + mergeFn := registry.Get(key) + if mergeFn == nil { + result.Args = customOpt.Args + return result, nil + } + + mergedArgs, err := mergeFn(baseOpt.Args, customOpt.Args) + if err != nil { + return config.RuleOptionsConfig{}, err + } + result.Args = mergedArgs + + return result, nil +} + +func mergeSkip(baseSkip, customSkip *config.RuleOptionSkipConfig) *config.RuleOptionSkipConfig { + if customSkip != nil && customSkip.Enabled { + return customSkip + } + if baseSkip != nil && baseSkip.Enabled { + return baseSkip + } + return nil +} + +type rulesetIndexKey struct { + ID string + Version string +} + +func indexProviders(providers []config.ProviderConfig) map[string]*config.ProviderConfig { + m := make(map[string]*config.ProviderConfig, len(providers)) + for i := range providers { + m[providers[i].ID] = &providers[i] + } + return m +} + +func indexRulesets(rulesets []config.RulesetConfig) map[rulesetIndexKey]*config.RulesetConfig { + m := make(map[rulesetIndexKey]*config.RulesetConfig, len(rulesets)) + for i := range rulesets { + key := rulesetIndexKey{ID: rulesets[i].ID, Version: rulesets[i].Version} + m[key] = &rulesets[i] + } + return m +} diff --git a/pkg/config/merge/merge_suite_test.go b/pkg/config/merge/merge_suite_test.go new file mode 100644 index 000000000..78151a95a --- /dev/null +++ b/pkg/config/merge/merge_suite_test.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package merge_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMerge(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Merge Test Suite") +} diff --git a/pkg/config/merge/merge_test.go b/pkg/config/merge/merge_test.go new file mode 100644 index 000000000..7f83da4a5 --- /dev/null +++ b/pkg/config/merge/merge_test.go @@ -0,0 +1,663 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package merge_test + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/gardener/diki/pkg/config" + "github.com/gardener/diki/pkg/config/merge" + "github.com/gardener/diki/pkg/shared/kubernetes/option" +) + +type mergeableOptions struct { + Items []string `json:"items"` +} + +var _ option.MergeableOption = &mergeableOptions{} + +func (o *mergeableOptions) Merge(other option.MergeableOption) (option.MergeableOption, error) { + otherOpts, ok := other.(*mergeableOptions) + if !ok { + return nil, fmt.Errorf("cannot merge %T into *mergeableOptions", other) + } + merged := &mergeableOptions{ + Items: make([]string, 0, len(o.Items)+len(otherOpts.Items)), + } + merged.Items = append(merged.Items, o.Items...) + merged.Items = append(merged.Items, otherOpts.Items...) + return merged, nil +} + +type nonMergeableOptions struct { + Value string `json:"value"` +} + +var _ = Describe("MergeConfigs", func() { + var registry *merge.Registry + + BeforeEach(func() { + registry = merge.NewRegistry() + merge.RegisterMergeFunc[mergeableOptions](registry, merge.RegistryKey{ + ProviderID: "test-provider", + RulesetID: "test-ruleset", + Version: "v1.0", + RuleID: "rule-mergeable", + }) + merge.RegisterMergeFunc[nonMergeableOptions](registry, merge.RegistryKey{ + ProviderID: "test-provider", + RulesetID: "test-ruleset", + Version: "v1.0", + RuleID: "rule-nonmergeable", + }) + }) + + It("should return custom config when base is nil", func() { + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + {ID: "test-provider"}, + }, + } + + result, err := merge.MergeConfigs(nil, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(custom)) + }) + + It("should return nil when custom is nil", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + {ID: "test-provider"}, + }, + } + + result, err := merge.MergeConfigs(base, nil, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeNil()) + }) + + It("should pass through custom provider unchanged when base has no matching provider", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + {ID: "other-provider"}, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-1", Args: map[string]any{"value": "custom"}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions).To(HaveLen(1)) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].RuleID).To(Equal("rule-1")) + }) + + It("should pass through custom ruleset unchanged when base has no matching ruleset", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + {ID: "other-ruleset", Version: "v1.0"}, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-1", Args: map[string]any{"value": "custom"}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions).To(HaveLen(1)) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].RuleID).To(Equal("rule-1")) + }) + + It("should keep custom-only rules as-is", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{}, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-custom-only", Args: map[string]any{"key": "val"}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions).To(HaveLen(1)) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].RuleID).To(Equal("rule-custom-only")) + }) + + It("should append base-only rules to the output", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-base-only", Args: map[string]any{"key": "base"}}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{}, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions).To(HaveLen(1)) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].RuleID).To(Equal("rule-base-only")) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Args).To(Equal(map[string]any{"key": "base"})) + }) + + It("should use custom args when rule exists in both but is not mergeable", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-nonmergeable", Args: map[string]any{"value": "base"}}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-nonmergeable", Args: map[string]any{"value": "custom"}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions).To(HaveLen(1)) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Args).To(Equal(map[string]any{"value": "custom"})) + }) + + It("should merge args when rule implements MergeableOption", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-mergeable", Args: map[string]any{"items": []any{"base-item"}}}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-mergeable", Args: map[string]any{"items": []any{"custom-item"}}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions).To(HaveLen(1)) + mergedArgs := result.Providers[0].Rulesets[0].RuleOptions[0].Args + mergedMap, ok := mergedArgs.(map[string]any) + Expect(ok).To(BeTrue()) + items, ok := mergedMap["items"].([]any) + Expect(ok).To(BeTrue()) + Expect(items).To(ConsistOf("base-item", "custom-item")) + }) + + It("should use custom args when rule is not in registry", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-unknown", Args: map[string]any{"value": "base"}}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-unknown", Args: map[string]any{"value": "custom"}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Args).To(Equal(map[string]any{"value": "custom"})) + }) + + Context("skip handling", func() { + It("should skip when base skips a rule", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + { + RuleID: "rule-nonmergeable", + Skip: &config.RuleOptionSkipConfig{Enabled: true, Justification: "base skip reason"}, + Args: map[string]any{"value": "base"}, + }, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-nonmergeable", Args: map[string]any{"value": "custom"}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Skip).ToNot(BeNil()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Skip.Enabled).To(BeTrue()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Skip.Justification).To(Equal("base skip reason")) + }) + + It("should skip when custom skips a rule", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-nonmergeable", Args: map[string]any{"value": "base"}}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + { + RuleID: "rule-nonmergeable", + Skip: &config.RuleOptionSkipConfig{Enabled: true, Justification: "custom skip reason"}, + Args: map[string]any{"value": "custom"}, + }, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Skip).ToNot(BeNil()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Skip.Enabled).To(BeTrue()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Skip.Justification).To(Equal("custom skip reason")) + }) + + It("should prefer custom justification when both skip", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + { + RuleID: "rule-nonmergeable", + Skip: &config.RuleOptionSkipConfig{Enabled: true, Justification: "base reason"}, + }, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + { + RuleID: "rule-nonmergeable", + Skip: &config.RuleOptionSkipConfig{Enabled: true, Justification: "custom reason"}, + }, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Skip.Justification).To(Equal("custom reason")) + }) + }) + + It("should preserve custom rule ordering and append base-only rules at the end", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-base-1"}, + {RuleID: "rule-both"}, + {RuleID: "rule-base-2"}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-custom-1"}, + {RuleID: "rule-both"}, + {RuleID: "rule-custom-2"}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + ruleIDs := make([]string, 0, len(result.Providers[0].Rulesets[0].RuleOptions)) + for _, opt := range result.Providers[0].Rulesets[0].RuleOptions { + ruleIDs = append(ruleIDs, opt.RuleID) + } + Expect(ruleIDs).To(Equal([]string{"rule-custom-1", "rule-both", "rule-custom-2", "rule-base-1", "rule-base-2"})) + }) + + It("should use base args when custom args is nil but base has args", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-nonmergeable", Args: map[string]any{"value": "base"}}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-nonmergeable"}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].Args).To(Equal(map[string]any{"value": "base"})) + }) + + It("should not match rulesets with different versions", func() { + base := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v2.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-base", Args: map[string]any{"from": "base"}}, + }, + }, + }, + }, + }, + } + custom := &config.DikiConfig{ + Providers: []config.ProviderConfig{ + { + ID: "test-provider", + Rulesets: []config.RulesetConfig{ + { + ID: "test-ruleset", + Version: "v1.0", + RuleOptions: []config.RuleOptionsConfig{ + {RuleID: "rule-custom", Args: map[string]any{"from": "custom"}}, + }, + }, + }, + }, + }, + } + + result, err := merge.MergeConfigs(base, custom, registry) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Providers[0].Rulesets[0].RuleOptions).To(HaveLen(1)) + Expect(result.Providers[0].Rulesets[0].RuleOptions[0].RuleID).To(Equal("rule-custom")) + }) +}) + +var _ = Describe("Registry", func() { + It("should return nil for unregistered key", func() { + registry := merge.NewRegistry() + fn := registry.Get(merge.RegistryKey{ProviderID: "x", RulesetID: "y", Version: "z", RuleID: "w"}) + Expect(fn).To(BeNil()) + }) + + It("should register and retrieve a merge function", func() { + registry := merge.NewRegistry() + merge.RegisterMergeFunc[mergeableOptions](registry, merge.RegistryKey{ + ProviderID: "p", + RulesetID: "r", + Version: "v", + RuleID: "rule", + }) + + fn := registry.Get(merge.RegistryKey{ProviderID: "p", RulesetID: "r", Version: "v", RuleID: "rule"}) + Expect(fn).ToNot(BeNil()) + + result, err := fn( + map[string]any{"items": []any{"a"}}, + map[string]any{"items": []any{"b"}}, + ) + Expect(err).ToNot(HaveOccurred()) + merged, ok := result.(map[string]any) + Expect(ok).To(BeTrue()) + items, ok := merged["items"].([]any) + Expect(ok).To(BeTrue()) + Expect(items).To(Equal([]any{"a", "b"})) + }) + + It("should return custom args for non-mergeable options", func() { + registry := merge.NewRegistry() + merge.RegisterMergeFunc[nonMergeableOptions](registry, merge.RegistryKey{ + ProviderID: "p", + RulesetID: "r", + Version: "v", + RuleID: "rule", + }) + + fn := registry.Get(merge.RegistryKey{ProviderID: "p", RulesetID: "r", Version: "v", RuleID: "rule"}) + Expect(fn).ToNot(BeNil()) + + customArgs := map[string]any{"value": "custom"} + result, err := fn( + map[string]any{"value": "base"}, + customArgs, + ) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal(customArgs)) + }) +}) diff --git a/pkg/config/merge/registry.go b/pkg/config/merge/registry.go new file mode 100644 index 000000000..c009fdefc --- /dev/null +++ b/pkg/config/merge/registry.go @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package merge + +import ( + "encoding/json" + "fmt" + + "github.com/gardener/diki/pkg/shared/kubernetes/option" +) + +// RegistryKey uniquely identifies a rule option type within a specific provider, ruleset, and version. +type RegistryKey struct { + ProviderID string + RulesetID string + Version string + RuleID string +} + +// RuleOptionMergeFunc merges base args with custom args for a specific rule. +// If the option type implements MergeableOption, it calls Merge; otherwise it returns customArgs unchanged. +type RuleOptionMergeFunc func(baseArgs, customArgs any) (any, error) + +// Registry maps rule option types to their merge functions. +type Registry struct { + funcs map[RegistryKey]RuleOptionMergeFunc +} + +// NewRegistry creates a new empty Registry. +func NewRegistry() *Registry { + return &Registry{ + funcs: make(map[RegistryKey]RuleOptionMergeFunc), + } +} + +// Register adds a merge function for a given key. +func (r *Registry) Register(key RegistryKey, fn RuleOptionMergeFunc) { + r.funcs[key] = fn +} + +// Get retrieves the merge function for a given key, or nil if not registered. +func (r *Registry) Get(key RegistryKey) RuleOptionMergeFunc { + return r.funcs[key] +} + +// RegisterMergeFunc registers a typed merge function for the given key. +// It parses raw args into the concrete type O and checks if it implements MergeableOption. +func RegisterMergeFunc[O any](r *Registry, key RegistryKey) { + r.Register(key, func(baseArgs, customArgs any) (any, error) { + baseOpt, err := parseOption[O](baseArgs) + if err != nil { + return nil, fmt.Errorf("failed to parse base args for rule %s: %w", key.RuleID, err) + } + + customOpt, err := parseOption[O](customArgs) + if err != nil { + return nil, fmt.Errorf("failed to parse custom args for rule %s: %w", key.RuleID, err) + } + + baseMergeable, ok := any(baseOpt).(option.MergeableOption) + if !ok { + return customArgs, nil + } + + customMergeable, ok := any(customOpt).(option.MergeableOption) + if !ok { + return customArgs, nil + } + + merged, err := baseMergeable.Merge(customMergeable) + if err != nil { + return nil, fmt.Errorf("failed to merge args for rule %s: %w", key.RuleID, err) + } + + return toRawMap(merged) + }) +} + +func parseOption[O any](args any) (*O, error) { + if args == nil { + return nil, nil + } + + data, err := json.Marshal(args) + if err != nil { + return nil, err + } + + var opt O + if err := json.Unmarshal(data, &opt); err != nil { + return nil, err + } + + return &opt, nil +} + +func toRawMap(v any) (any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + return raw, nil +} diff --git a/pkg/provider/garden/ruleset/securityhardenedshoot/merge_registry.go b/pkg/provider/garden/ruleset/securityhardenedshoot/merge_registry.go new file mode 100644 index 000000000..e4311a302 --- /dev/null +++ b/pkg/provider/garden/ruleset/securityhardenedshoot/merge_registry.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package securityhardenedshoot + +import ( + "github.com/gardener/diki/pkg/config/merge" + "github.com/gardener/diki/pkg/provider/garden" + "github.com/gardener/diki/pkg/provider/garden/ruleset/securityhardenedshoot/rules" +) + +// RegisterMergeFuncs registers all rule option merge functions for the +// Security Hardened Shoot Cluster ruleset (garden provider). +func RegisterMergeFuncs(r *merge.Registry) { + for _, version := range SupportedVersions { + switch version { + case "v0.2.1", "v0.2.0": + registerV02MergeFuncs(r, version) + case "v0.1.0": + registerV01MergeFuncs(r, version) + } + } +} + +func registerV02MergeFuncs(r *merge.Registry, version string) { + key := func(ruleID string) merge.RegistryKey { + return merge.RegistryKey{ + ProviderID: garden.ProviderID, + RulesetID: RulesetID, + Version: version, + RuleID: ruleID, + } + } + + merge.RegisterMergeFunc[rules.Options1000](r, key("1000")) + merge.RegisterMergeFunc[rules.Options1001](r, key("1001")) + merge.RegisterMergeFunc[rules.Options1002](r, key("1002")) + merge.RegisterMergeFunc[rules.Options1003](r, key("1003")) + merge.RegisterMergeFunc[rules.Options2000](r, key("2000")) + merge.RegisterMergeFunc[rules.Options2007](r, key("2007")) +} + +func registerV01MergeFuncs(r *merge.Registry, version string) { + key := func(ruleID string) merge.RegistryKey { + return merge.RegistryKey{ + ProviderID: garden.ProviderID, + RulesetID: RulesetID, + Version: version, + RuleID: ruleID, + } + } + + merge.RegisterMergeFunc[rules.Options1000](r, key("1000")) + merge.RegisterMergeFunc[rules.Options2000](r, key("2000")) + merge.RegisterMergeFunc[rules.Options2007](r, key("2007")) +} diff --git a/pkg/provider/gardener/ruleset/disak8sstig/merge_registry.go b/pkg/provider/gardener/ruleset/disak8sstig/merge_registry.go new file mode 100644 index 000000000..1b3a61968 --- /dev/null +++ b/pkg/provider/gardener/ruleset/disak8sstig/merge_registry.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package disak8sstig + +import ( + "github.com/gardener/diki/pkg/config/merge" + "github.com/gardener/diki/pkg/provider/gardener" + "github.com/gardener/diki/pkg/provider/gardener/ruleset/disak8sstig/rules" + disaoption "github.com/gardener/diki/pkg/shared/ruleset/disak8sstig/option" + sharedrules "github.com/gardener/diki/pkg/shared/ruleset/disak8sstig/rules" +) + +// RegisterMergeFuncs registers all rule option merge functions for the +// DISA Kubernetes STIG ruleset (gardener provider). +func RegisterMergeFuncs(r *merge.Registry) { + for _, version := range SupportedVersions { + registerMergeFuncs(r, version) + } +} + +func registerMergeFuncs(r *merge.Registry, version string) { + key := func(ruleID string) merge.RegistryKey { + return merge.RegistryKey{ + ProviderID: gardener.ProviderID, + RulesetID: RulesetID, + Version: version, + RuleID: ruleID, + } + } + + merge.RegisterMergeFunc[sharedrules.Options242390](r, key(sharedrules.ID242390)) + merge.RegisterMergeFunc[rules.Options242400](r, key(sharedrules.ID242400)) + merge.RegisterMergeFunc[disaoption.Options242414](r, key(sharedrules.ID242414)) + merge.RegisterMergeFunc[disaoption.Options242415](r, key(sharedrules.ID242415)) + merge.RegisterMergeFunc[disaoption.Options242442](r, key(sharedrules.ID242442)) + merge.RegisterMergeFunc[disaoption.FileOwnerOptions](r, key(sharedrules.ID242445)) + merge.RegisterMergeFunc[disaoption.FileOwnerOptions](r, key(sharedrules.ID242446)) + merge.RegisterMergeFunc[rules.Options242451](r, key(sharedrules.ID242451)) + merge.RegisterMergeFunc[rules.Options242466](r, key(sharedrules.ID242466)) + merge.RegisterMergeFunc[rules.Options242467](r, key(sharedrules.ID242467)) + merge.RegisterMergeFunc[sharedrules.Options245543](r, key(sharedrules.ID245543)) + merge.RegisterMergeFunc[sharedrules.Options254800](r, key(sharedrules.ID254800)) +} diff --git a/pkg/provider/managedk8s/ruleset/disak8sstig/merge_registry.go b/pkg/provider/managedk8s/ruleset/disak8sstig/merge_registry.go new file mode 100644 index 000000000..001c05f5c --- /dev/null +++ b/pkg/provider/managedk8s/ruleset/disak8sstig/merge_registry.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package disak8sstig + +import ( + "github.com/gardener/diki/pkg/config/merge" + "github.com/gardener/diki/pkg/provider/managedk8s" + "github.com/gardener/diki/pkg/provider/managedk8s/ruleset/disak8sstig/rules" + "github.com/gardener/diki/pkg/shared/kubernetes/option" + disaoption "github.com/gardener/diki/pkg/shared/ruleset/disak8sstig/option" + sharedrules "github.com/gardener/diki/pkg/shared/ruleset/disak8sstig/rules" +) + +// RegisterMergeFuncs registers all rule option merge functions for the +// DISA Kubernetes STIG ruleset (managedk8s provider). +func RegisterMergeFuncs(r *merge.Registry) { + for _, version := range SupportedVersions { + registerMergeFuncs(r, version) + } +} + +func registerMergeFuncs(r *merge.Registry, version string) { + key := func(ruleID string) merge.RegistryKey { + return merge.RegistryKey{ + ProviderID: managedk8s.ProviderID, + RulesetID: RulesetID, + Version: version, + RuleID: ruleID, + } + } + + merge.RegisterMergeFunc[sharedrules.Options242383](r, key(sharedrules.ID242383)) + merge.RegisterMergeFunc[sharedrules.Options242393](r, key(sharedrules.ID242393)) + merge.RegisterMergeFunc[sharedrules.Options242394](r, key(sharedrules.ID242394)) + merge.RegisterMergeFunc[sharedrules.Options242396](r, key(sharedrules.ID242396)) + merge.RegisterMergeFunc[rules.Options242400](r, key(sharedrules.ID242400)) + merge.RegisterMergeFunc[sharedrules.Options242404](r, key(sharedrules.ID242404)) + merge.RegisterMergeFunc[sharedrules.Options242406](r, key(sharedrules.ID242406)) + merge.RegisterMergeFunc[sharedrules.Options242407](r, key(sharedrules.ID242407)) + merge.RegisterMergeFunc[disaoption.Options242414](r, key(sharedrules.ID242414)) + merge.RegisterMergeFunc[disaoption.Options242415](r, key(sharedrules.ID242415)) + merge.RegisterMergeFunc[sharedrules.Options242417](r, key(sharedrules.ID242417)) + merge.RegisterMergeFunc[rules.Options242442](r, key(sharedrules.ID242442)) + merge.RegisterMergeFunc[option.ClusterObjectSelector](r, key(sharedrules.ID242447)) + merge.RegisterMergeFunc[sharedrules.Options242448](r, key(sharedrules.ID242448)) + merge.RegisterMergeFunc[sharedrules.Options242449](r, key(sharedrules.ID242449)) + merge.RegisterMergeFunc[sharedrules.Options242450](r, key(sharedrules.ID242450)) + merge.RegisterMergeFunc[rules.Options242451](r, key(sharedrules.ID242451)) + merge.RegisterMergeFunc[sharedrules.Options242452](r, key(sharedrules.ID242452)) + merge.RegisterMergeFunc[sharedrules.Options242453](r, key(sharedrules.ID242453)) + merge.RegisterMergeFunc[rules.Options242466](r, key(sharedrules.ID242466)) + merge.RegisterMergeFunc[rules.Options242467](r, key(sharedrules.ID242467)) +} diff --git a/pkg/provider/managedk8s/ruleset/securityhardenedk8s/merge_registry.go b/pkg/provider/managedk8s/ruleset/securityhardenedk8s/merge_registry.go new file mode 100644 index 000000000..f4f900135 --- /dev/null +++ b/pkg/provider/managedk8s/ruleset/securityhardenedk8s/merge_registry.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package securityhardenedk8s + +import ( + "github.com/gardener/diki/pkg/config/merge" + "github.com/gardener/diki/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules" +) + +// RegisterMergeFuncs registers all rule option merge functions for the +// Security Hardened Kubernetes Cluster ruleset (managedk8s provider). +func RegisterMergeFuncs(r *merge.Registry) { + for _, version := range SupportedVersions { + registerV01MergeFuncs(r, version) + } +} + +func registerV01MergeFuncs(r *merge.Registry, version string) { + key := func(ruleID string) merge.RegistryKey { + return merge.RegistryKey{ + ProviderID: "managedk8s", + RulesetID: RulesetID, + Version: version, + RuleID: ruleID, + } + } + + merge.RegisterMergeFunc[rules.Options2000](r, key("2000")) + merge.RegisterMergeFunc[rules.Options2001](r, key("2001")) + merge.RegisterMergeFunc[rules.Options2002](r, key("2002")) + merge.RegisterMergeFunc[rules.Options2003](r, key("2003")) + merge.RegisterMergeFunc[rules.Options2004](r, key("2004")) + merge.RegisterMergeFunc[rules.Options2005](r, key("2005")) + merge.RegisterMergeFunc[rules.Options2006](r, key("2006")) + merge.RegisterMergeFunc[rules.Options2007](r, key("2007")) + merge.RegisterMergeFunc[rules.Options2008](r, key("2008")) +} diff --git a/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000.go b/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000.go index bf0d9c708..e70cf6a6a 100644 --- a/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000.go +++ b/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000.go @@ -6,6 +6,7 @@ package rules import ( "context" + "fmt" "slices" networkingv1 "k8s.io/api/networking/v1" @@ -19,8 +20,9 @@ import ( ) var ( - _ rule.Rule = &Rule2000{} - _ rule.Severity = &Rule2000{} + _ rule.Rule = &Rule2000{} + _ rule.Severity = &Rule2000{} + _ option.MergeableOption = &Options2000{} ) type Rule2000 struct { @@ -42,6 +44,27 @@ type AcceptedTraffic struct { Ingress bool `json:"ingress" yaml:"ingress"` } +// Merge returns a new Options2000 that is the result of merging other into the receiver. +// AcceptedNamespaces slices are concatenated. If other is not *Options2000, an error is returned. +func (o *Options2000) Merge(other option.MergeableOption) (option.MergeableOption, error) { + if other == nil { + return o, nil + } + + otherOpts, ok := other.(*Options2000) + if !ok { + return nil, fmt.Errorf("cannot merge options of type %T into *Options2000", other) + } + + merged := &Options2000{ + AcceptedNamespaces: make([]AcceptedNamespaces2000, 0, len(o.AcceptedNamespaces)+len(otherOpts.AcceptedNamespaces)), + } + merged.AcceptedNamespaces = append(merged.AcceptedNamespaces, o.AcceptedNamespaces...) + merged.AcceptedNamespaces = append(merged.AcceptedNamespaces, otherOpts.AcceptedNamespaces...) + + return merged, nil +} + func (r *Rule2000) ID() string { return "2000" } diff --git a/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000_test.go b/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000_test.go index f81cec76d..cb577dd0c 100644 --- a/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000_test.go +++ b/pkg/provider/managedk8s/ruleset/securityhardenedk8s/rules/2000_test.go @@ -883,4 +883,77 @@ var _ = Describe("#2000", func() { })) }) }) + + Describe("#Merge Options2000", func() { + It("should merge two Options2000 by appending AcceptedNamespaces", func() { + base := &rules.Options2000{ + AcceptedNamespaces: []rules.AcceptedNamespaces2000{ + { + AcceptedClusterObject: option.AcceptedClusterObject{ + ClusterObjectSelector: option.ClusterObjectSelector{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"env": "prod"}, + }, + }, + Justification: "base justification", + }, + AcceptedTraffic: rules.AcceptedTraffic{Ingress: true, Egress: true}, + }, + }, + } + + override := &rules.Options2000{ + AcceptedNamespaces: []rules.AcceptedNamespaces2000{ + { + AcceptedClusterObject: option.AcceptedClusterObject{ + ClusterObjectSelector: option.ClusterObjectSelector{ + LabelSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"team": "security"}, + }, + }, + Justification: "override justification", + }, + AcceptedTraffic: rules.AcceptedTraffic{Ingress: false, Egress: true}, + }, + }, + } + + merged, err := base.Merge(override) + Expect(err).ToNot(HaveOccurred()) + + mergedOpts, ok := merged.(*rules.Options2000) + Expect(ok).To(BeTrue()) + Expect(mergedOpts.AcceptedNamespaces).To(HaveLen(2)) + Expect(mergedOpts.AcceptedNamespaces[0].Justification).To(Equal("base justification")) + Expect(mergedOpts.AcceptedNamespaces[1].Justification).To(Equal("override justification")) + }) + + It("should return the receiver when merging with nil", func() { + base := &rules.Options2000{ + AcceptedNamespaces: []rules.AcceptedNamespaces2000{ + { + AcceptedClusterObject: option.AcceptedClusterObject{ + Justification: "base", + }, + }, + }, + } + + merged, err := base.Merge(nil) + Expect(err).ToNot(HaveOccurred()) + Expect(merged).To(Equal(base)) + }) + + It("should handle merging two empty Options2000", func() { + base := &rules.Options2000{} + other := &rules.Options2000{} + + merged, err := base.Merge(other) + Expect(err).ToNot(HaveOccurred()) + + mergedOpts, ok := merged.(*rules.Options2000) + Expect(ok).To(BeTrue()) + Expect(mergedOpts.AcceptedNamespaces).To(BeEmpty()) + }) + }) }) diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index ea554942c..e38a87421 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/util/validation/field" "github.com/gardener/diki/pkg/config" + "github.com/gardener/diki/pkg/config/merge" "github.com/gardener/diki/pkg/metadata" "github.com/gardener/diki/pkg/rule" "github.com/gardener/diki/pkg/ruleset" @@ -42,9 +43,13 @@ type MetadataFunc func() metadata.ProviderDetailed // DefaultDikiConfigFunc constructs a default [config.DikiConfig] for a specific provider. type DefaultDikiConfigFunc func() config.DikiConfig +// MergeRegistryFunc registers merge functions for a provider's rulesets into a [merge.Registry]. +type MergeRegistryFunc func(r *merge.Registry) + // ProviderOption constructs a pair of a configuration and metadata function for a specific provider. type ProviderOption struct { ProviderFromConfigFunc MetadataFunc DefaultDikiConfigFunc + MergeRegistryFunc } diff --git a/pkg/provider/virtualgarden/ruleset/disak8sstig/merge_registry.go b/pkg/provider/virtualgarden/ruleset/disak8sstig/merge_registry.go new file mode 100644 index 000000000..ca29b4d22 --- /dev/null +++ b/pkg/provider/virtualgarden/ruleset/disak8sstig/merge_registry.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package disak8sstig + +import ( + "github.com/gardener/diki/pkg/config/merge" + "github.com/gardener/diki/pkg/provider/virtualgarden" + disaoption "github.com/gardener/diki/pkg/shared/ruleset/disak8sstig/option" + sharedrules "github.com/gardener/diki/pkg/shared/ruleset/disak8sstig/rules" +) + +// RegisterMergeFuncs registers all rule option merge functions for the +// DISA Kubernetes STIG ruleset (virtualgarden provider). +func RegisterMergeFuncs(r *merge.Registry) { + for _, version := range SupportedVersions { + registerMergeFuncs(r, version) + } +} + +func registerMergeFuncs(r *merge.Registry, version string) { + key := func(ruleID string) merge.RegistryKey { + return merge.RegistryKey{ + ProviderID: virtualgarden.ProviderID, + RulesetID: RulesetID, + Version: version, + RuleID: ruleID, + } + } + + merge.RegisterMergeFunc[sharedrules.Options242390](r, key(sharedrules.ID242390)) + merge.RegisterMergeFunc[disaoption.Options242442](r, key(sharedrules.ID242442)) + merge.RegisterMergeFunc[disaoption.FileOwnerOptions](r, key(sharedrules.ID242445)) + merge.RegisterMergeFunc[disaoption.FileOwnerOptions](r, key(sharedrules.ID242446)) + merge.RegisterMergeFunc[disaoption.FileOwnerOptions](r, key(sharedrules.ID242451)) + merge.RegisterMergeFunc[sharedrules.Options245543](r, key(sharedrules.ID245543)) +} diff --git a/pkg/shared/kubernetes/option/options.go b/pkg/shared/kubernetes/option/options.go index af57f1b3b..602653f9a 100644 --- a/pkg/shared/kubernetes/option/options.go +++ b/pkg/shared/kubernetes/option/options.go @@ -23,6 +23,11 @@ type Option interface { Validate(fldPath *field.Path) field.ErrorList } +// MergeableOption is an Option that can be merged with another option of the same type. +type MergeableOption interface { + Merge(other MergeableOption) (MergeableOption, error) +} + // ClusterObjectSelector contains generalized options for matching entities by their attribute labels. type ClusterObjectSelector struct { // Deprecated: This field is deprecated and will be forbidden in a future release.