diff --git a/cmd/flux/audit.go b/cmd/flux/audit.go new file mode 100644 index 00000000..1345eb02 --- /dev/null +++ b/cmd/flux/audit.go @@ -0,0 +1,195 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/fluxcd/flux2/v2/internal/utils" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + v1 "k8s.io/api/apps/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1" +) + +var ctrlChecks = map[string]map[string]bool{ + "helm-controller": { + "insecure-kubeconfig-exec": false, + "insecure-kubeconfig-tls": false, + }, + "kustomize-controller": { + "insecure-kubeconfig-exec": false, + "insecure-kubeconfig-tls": false, + "no-remote-bases": true, + }, +} + +var multiTenancyCtrlChecks = map[string]map[string]bool{ + "helm-controller": { + "no-cross-namespace-refs": true, + }, + "kustomize-controller": { + "no-cross-namespace-refs": true, + }, + "notification-controller": { + "no-cross-namespace-refs": true, + }, + "image-reflector-controller": { + "no-cross-namespace-refs": true, + }, + "image-automation-controller": { + "no-cross-namespace-refs": true, + }, +} + +var multiTenancyFlag bool + +var auditCmd = &cobra.Command{ + Use: "audit", + Short: "Audit the Flux installation for security best practices", + Long: withPreviewNote("TBD"), + Example: ` TBD`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), rootArgs.timeout) + defer cancel() + + kubeClient, err := utils.KubeClient(kubeconfigArgs, kubeclientOptions) + if err != nil { + return err + } + + logger.Actionf("Starting audit") + + for ctrl, checks := range ctrlChecks { + if err := auditController(ctx, kubeClient, ctrl, checks); err != nil { + return fmt.Errorf("failed auditing %s: %w", ctrl, err) + } + } + + if err := auditSecretDecryption(ctx, kubeClient); err != nil { + return fmt.Errorf("failed auditing Secret decryption: %w", err) + } + + if multiTenancyFlag { + logger.Actionf("Multi-tenancy lock-down") + for ctrl, checks := range multiTenancyCtrlChecks { + if err := auditController(ctx, kubeClient, ctrl, checks); err != nil { + return fmt.Errorf("failed auditing %s for multi-tenancy lock-down: %w", ctrl, err) + } + } + } + + return nil + }, +} + +func auditSecretDecryption(ctx context.Context, c client.Client) error { + var ksl kustomizev1.KustomizationList + if err := c.List(ctx, &ksl); err != nil { + return fmt.Errorf("failed to retrieve Kustomizations: %w", err) + } + + success := true + for _, ks := range ksl.Items { + if ks.Status.Inventory == nil { + continue + } + if ks.Spec.Decryption != nil { + continue + } + for _, e := range ks.Status.Inventory.Entries { + parts := strings.Split(e.ID, "_") + if parts[2] == "" && parts[3] == "Secret" { + success = false + logger.Warningf("%s/%s doesn't have Secret decryption configured", ks.Namespace, ks.Name) + } + } + } + + if success { + logger.Successf("Secret decryption is configured for all Kustomizations that create Secrets") + } + + return nil +} + +func auditController(ctx context.Context, c client.Client, name string, flags map[string]bool) error { + hcDeploys, err := getManagerArgs(ctx, c, name) + if err != nil { + return fmt.Errorf("failed to get %s flags: %w", name, err) + } + + if len(hcDeploys) == 0 { + logger.Warningf("No %s Deployment found, auditing skipped", name) + } else { + for name, args := range hcDeploys { + for flag, desired := range flags { + hcExec, err := assertBoolFlagValue(args, flag, desired) + if err != nil { + return fmt.Errorf("failed parsing %q args: %w", name, err) + } + if hcExec == desired { + logger.Successf("%s: %s is %t", name, flag, desired) + } else { + logger.Warningf("%s: %s should be %t", name, flag, desired) + } + } + } + } + return nil +} + +func getManagerArgs(ctx context.Context, c client.Client, component string) (map[string][]string, error) { + var deploys v1.DeploymentList + if err := c.List(ctx, &deploys, client.MatchingLabels{ + "app.kubernetes.io/component": component, + }); err != nil { + return nil, fmt.Errorf("failed to retrieve %s deployments: %w", component, err) + } + + res := make(map[string][]string, 0) + + for _, deploy := range deploys.Items { + for _, ctr := range deploy.Spec.Template.Spec.Containers { + if ctr.Name == "manager" { + res[deploy.Name] = ctr.Args + } + } + } + + return res, nil +} + +func assertBoolFlagValue(args []string, flagName string, value bool) (bool, error) { + fs := pflag.NewFlagSet("tmp", pflag.ContinueOnError) + fs.ParseErrorsWhitelist.UnknownFlags = true + f := fs.BoolP(flagName, "", false, "") + if err := fs.Parse(args); err != nil { + return false, fmt.Errorf("failed parsing args: %w", err) + } + return *f, nil +} + +func init() { + auditCmd.Flags().BoolVar(&multiTenancyFlag, "multi-tenancy", false, "Enable additional audit checks for multi-tenant clusters.") + rootCmd.AddCommand(auditCmd) +}