diff --git a/cmd/fix/fix.go b/cmd/fix/fix.go new file mode 100644 index 00000000..33ee495c --- /dev/null +++ b/cmd/fix/fix.go @@ -0,0 +1,45 @@ +package fix + +import ( + "errors" + + "github.com/kubescape/kubescape/v2/core/meta" + metav1 "github.com/kubescape/kubescape/v2/core/meta/datastructures/v1" + + "github.com/spf13/cobra" +) + +var fixCmdExamples = ` + Fix command is for fixing kubernetes manifest files based on a scan command output. + Use with caution, this command will change your files in-place. + + # Fix kubernetes YAML manifest files based on a scan command output (output.json) + 1) kubescape scan --format json --format-version v2 --output output.json + 2) kubescape fix output.json + +` + +func GetFixCmd(ks meta.IKubescape) *cobra.Command { + var fixInfo metav1.FixInfo + + fixCmd := &cobra.Command{ + Use: "fix ", + Short: "Fix misconfiguration in files", + Long: ``, + Example: fixCmdExamples, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) < 1 { + return errors.New("report output file is required") + } + fixInfo.ReportFile = args[0] + + return ks.Fix(&fixInfo) + }, + } + + fixCmd.PersistentFlags().BoolVar(&fixInfo.NoConfirm, "no-confirm", false, "No confirmation will be given to the user before applying the fix (default false)") + fixCmd.PersistentFlags().BoolVar(&fixInfo.DryRun, "dry-run", false, "No changes will be applied (default false)") + fixCmd.PersistentFlags().BoolVar(&fixInfo.SkipUserValues, "skip-user-values", true, "Changes which involve user-defined values will be skipped") + + return fixCmd +} diff --git a/cmd/root.go b/cmd/root.go index bcd3d671..7ea31546 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/kubescape/kubescape/v2/cmd/config" "github.com/kubescape/kubescape/v2/cmd/delete" "github.com/kubescape/kubescape/v2/cmd/download" + "github.com/kubescape/kubescape/v2/cmd/fix" "github.com/kubescape/kubescape/v2/cmd/list" "github.com/kubescape/kubescape/v2/cmd/scan" "github.com/kubescape/kubescape/v2/cmd/submit" @@ -78,6 +79,7 @@ func getRootCmd(ks meta.IKubescape) *cobra.Command { rootCmd.AddCommand(version.GetVersionCmd()) rootCmd.AddCommand(config.GetConfigCmd(ks)) rootCmd.AddCommand(update.GetUpdateCmd()) + rootCmd.AddCommand(fix.GetFixCmd(ks)) return rootCmd } diff --git a/core/core/fix.go b/core/core/fix.go new file mode 100644 index 00000000..2b2ffe98 --- /dev/null +++ b/core/core/fix.go @@ -0,0 +1,72 @@ +package core + +import ( + "fmt" + "strings" + + logger "github.com/kubescape/go-logger" + metav1 "github.com/kubescape/kubescape/v2/core/meta/datastructures/v1" + + "github.com/kubescape/kubescape/v2/core/pkg/fixhandler" +) + +const NoChangesApplied = "No changes were applied." +const NoResourcesToFix = "No issues to fix." +const ConfirmationQuestion = "Would you like to apply the changes to the files above? [y|n]: " + +func (ks *Kubescape) Fix(fixInfo *metav1.FixInfo) error { + logger.L().Info("Reading report file...") + handler, err := fixhandler.NewFixHandler(fixInfo) + if err != nil { + return err + } + + resourcesToFix := handler.PrepareResourcesToFix() + + if len(resourcesToFix) == 0 { + logger.L().Info(NoResourcesToFix) + return nil + } + + handler.PrintExpectedChanges(resourcesToFix) + + if fixInfo.DryRun { + logger.L().Info(NoChangesApplied) + return nil + } + + if !fixInfo.NoConfirm && !userConfirmed() { + logger.L().Info(NoChangesApplied) + return nil + } + + updatedFilesCount, errors := handler.ApplyChanges(resourcesToFix) + logger.L().Info(fmt.Sprintf("Fixed resources in %d files.", updatedFilesCount)) + + if len(errors) > 0 { + for _, err := range errors { + logger.L().Error(err.Error()) + } + return fmt.Errorf("Failed to fix some resources, check the logs for more details") + } + + return nil +} + +func userConfirmed() bool { + var input string + + for { + fmt.Printf(ConfirmationQuestion) + if _, err := fmt.Scanln(&input); err != nil { + continue + } + + input = strings.ToLower(input) + if input == "y" || input == "yes" { + return true + } else if input == "n" || input == "no" { + return false + } + } +} diff --git a/core/meta/datastructures/v1/fix.go b/core/meta/datastructures/v1/fix.go new file mode 100644 index 00000000..e4d7fe10 --- /dev/null +++ b/core/meta/datastructures/v1/fix.go @@ -0,0 +1,8 @@ +package v1 + +type FixInfo struct { + ReportFile string // path to report file (mandatory) + NoConfirm bool // if true, no confirmation will be given to the user before applying the fix + SkipUserValues bool // if true, user values will not be changed + DryRun bool // if true, no changes will be applied +} diff --git a/core/meta/ksinterface.go b/core/meta/ksinterface.go index 199b1710..4519ba62 100644 --- a/core/meta/ksinterface.go +++ b/core/meta/ksinterface.go @@ -25,4 +25,7 @@ type IKubescape interface { // delete DeleteExceptions(deleteexceptions *metav1.DeleteExceptions) error + + // fix + Fix(fixInfo *metav1.FixInfo) error } diff --git a/core/pkg/fixhandler/datastructures.go b/core/pkg/fixhandler/datastructures.go new file mode 100644 index 00000000..6b2601ae --- /dev/null +++ b/core/pkg/fixhandler/datastructures.go @@ -0,0 +1,22 @@ +package fixhandler + +import ( + "github.com/armosec/armoapi-go/armotypes" + metav1 "github.com/kubescape/kubescape/v2/core/meta/datastructures/v1" + "github.com/kubescape/opa-utils/reporthandling" + reporthandlingv2 "github.com/kubescape/opa-utils/reporthandling/v2" +) + +// FixHandler is a struct that holds the information of the report to be fixed +type FixHandler struct { + fixInfo *metav1.FixInfo + reportObj *reporthandlingv2.PostureReport + localBasePath string +} + +// ResourceFixInfo is a struct that holds the information about the resource that needs to be fixed +type ResourceFixInfo struct { + YamlExpressions map[string]*armotypes.FixPath + Resource *reporthandling.Resource + FilePath string +} diff --git a/core/pkg/fixhandler/fixhandler.go b/core/pkg/fixhandler/fixhandler.go new file mode 100644 index 00000000..26005b20 --- /dev/null +++ b/core/pkg/fixhandler/fixhandler.go @@ -0,0 +1,295 @@ +package fixhandler + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path" + "strconv" + "strings" + + "github.com/armosec/armoapi-go/armotypes" + metav1 "github.com/kubescape/kubescape/v2/core/meta/datastructures/v1" + + logger "github.com/kubescape/go-logger" + "github.com/kubescape/opa-utils/objectsenvelopes" + "github.com/kubescape/opa-utils/objectsenvelopes/localworkload" + "github.com/kubescape/opa-utils/reporthandling" + "github.com/kubescape/opa-utils/reporthandling/results/v1/resourcesresults" + reporthandlingv2 "github.com/kubescape/opa-utils/reporthandling/v2" + "github.com/mikefarah/yq/v4/pkg/yqlib" + "gopkg.in/op/go-logging.v1" +) + +const UserValuePrefix = "YOUR_" + +func NewFixHandler(fixInfo *metav1.FixInfo) (*FixHandler, error) { + jsonFile, err := os.Open(fixInfo.ReportFile) + if err != nil { + return nil, err + } + defer jsonFile.Close() + byteValue, _ := ioutil.ReadAll(jsonFile) + + var reportObj reporthandlingv2.PostureReport + if err = json.Unmarshal(byteValue, &reportObj); err != nil { + return nil, err + } + + if err = isSupportedScanningTarget(&reportObj); err != nil { + return nil, err + } + + localPath := getLocalPath(&reportObj) + if _, err = os.Stat(localPath); err != nil { + return nil, err + } + + backendLoggerLeveled := logging.AddModuleLevel(logging.NewLogBackend(logger.L().GetWriter(), "", 0)) + backendLoggerLeveled.SetLevel(logging.ERROR, "") + yqlib.GetLogger().SetBackend(backendLoggerLeveled) + + return &FixHandler{ + fixInfo: fixInfo, + reportObj: &reportObj, + localBasePath: localPath, + }, nil +} + +func isSupportedScanningTarget(report *reporthandlingv2.PostureReport) error { + if report.Metadata.ScanMetadata.ScanningTarget == reporthandlingv2.GitLocal || report.Metadata.ScanMetadata.ScanningTarget == reporthandlingv2.Directory { + return nil + } + + return fmt.Errorf("unsupported scanning target. Only local git and directory scanning targets are supported") +} + +func getLocalPath(report *reporthandlingv2.PostureReport) string { + if report.Metadata.ScanMetadata.ScanningTarget == reporthandlingv2.GitLocal { + return report.Metadata.ContextMetadata.RepoContextMetadata.LocalRootPath + } + + if report.Metadata.ScanMetadata.ScanningTarget == reporthandlingv2.Directory { + return report.Metadata.ContextMetadata.DirectoryContextMetadata.BasePath + } + + return "" +} + +func (h *FixHandler) buildResourcesMap() map[string]*reporthandling.Resource { + resourceIdToRawResource := make(map[string]*reporthandling.Resource) + for i := range h.reportObj.Resources { + resourceIdToRawResource[h.reportObj.Resources[i].GetID()] = &h.reportObj.Resources[i] + } + for i := range h.reportObj.Results { + if h.reportObj.Results[i].RawResource == nil { + continue + } + resourceIdToRawResource[h.reportObj.Results[i].RawResource.GetID()] = h.reportObj.Results[i].RawResource + } + + return resourceIdToRawResource +} + +func (h *FixHandler) getPathFromRawResource(obj map[string]interface{}) string { + if localworkload.IsTypeLocalWorkload(obj) { + localwork := localworkload.NewLocalWorkload(obj) + return localwork.GetPath() + } else if objectsenvelopes.IsTypeRegoResponseVector(obj) { + regoResponseVectorObject := objectsenvelopes.NewRegoResponseVectorObject(obj) + relatedObjects := regoResponseVectorObject.GetRelatedObjects() + for _, relatedObject := range relatedObjects { + if localworkload.IsTypeLocalWorkload(relatedObject.GetObject()) { + return relatedObject.(*localworkload.LocalWorkload).GetPath() + } + } + } + + return "" +} + +func (h *FixHandler) PrepareResourcesToFix() []ResourceFixInfo { + resourceIdToResource := h.buildResourcesMap() + + resourcesToFix := make([]ResourceFixInfo, 0) + for _, result := range h.reportObj.Results { + if !result.GetStatus(nil).IsFailed() { + continue + } + + resourceID := result.ResourceID + resourceObj := resourceIdToResource[resourceID] + resourcePath := h.getPathFromRawResource(resourceObj.GetObject()) + if resourcePath == "" { + continue + } + + if resourceObj.Source == nil || resourceObj.Source.FileType != reporthandling.SourceTypeYaml { + continue + } + + relativePath, documentIndex, err := h.getFilePathAndIndex(resourcePath) + if err != nil { + logger.L().Error("Skipping invalid resource path: " + resourcePath) + continue + } + + absolutePath := path.Join(h.localBasePath, relativePath) + if _, err := os.Stat(absolutePath); err != nil { + logger.L().Error("Skipping missing file: " + absolutePath) + continue + } + + rfi := ResourceFixInfo{ + FilePath: absolutePath, + Resource: resourceObj, + YamlExpressions: make(map[string]*armotypes.FixPath, 0), + } + + for i := range result.AssociatedControls { + if result.AssociatedControls[i].GetStatus(nil).IsFailed() { + rfi.addYamlExpressionsFromResourceAssociatedControl(documentIndex, &result.AssociatedControls[i], h.fixInfo.SkipUserValues) + } + } + + if len(rfi.YamlExpressions) > 0 { + resourcesToFix = append(resourcesToFix, rfi) + } + } + + return resourcesToFix +} + +func (h *FixHandler) PrintExpectedChanges(resourcesToFix []ResourceFixInfo) { + var sb strings.Builder + sb.WriteString("The following changes will be applied:\n") + + for _, resourceFixInfo := range resourcesToFix { + sb.WriteString(fmt.Sprintf("File: %s\n", resourceFixInfo.FilePath)) + sb.WriteString(fmt.Sprintf("Resource: %s\n", resourceFixInfo.Resource.GetName())) + sb.WriteString(fmt.Sprintf("Kind: %s\n", resourceFixInfo.Resource.GetKind())) + sb.WriteString("Changes:\n") + + i := 1 + for _, fixPath := range resourceFixInfo.YamlExpressions { + sb.WriteString(fmt.Sprintf("\t%d) %s = %s\n", i, (*fixPath).Path, (*fixPath).Value)) + i++ + } + sb.WriteString("\n------\n") + } + + logger.L().Info(sb.String()) +} + +func (h *FixHandler) ApplyChanges(resourcesToFix []ResourceFixInfo) (int, []error) { + updatedFiles := make(map[string]bool) + errors := make([]error, 0) + for _, resourceToFix := range resourcesToFix { + singleExpression := reduceYamlExpressions(&resourceToFix) + if err := h.applyFixToFile(resourceToFix.FilePath, singleExpression); err != nil { + errors = append(errors, + fmt.Errorf("failed to fix resource [Name: '%s', Kind: '%s'] in '%s': %w ", + resourceToFix.Resource.GetName(), + resourceToFix.Resource.GetKind(), + resourceToFix.FilePath, + err)) + } else { + updatedFiles[resourceToFix.FilePath] = true + } + } + return len(updatedFiles), errors +} + +func (h *FixHandler) getFilePathAndIndex(filePathWithIndex string) (filePath string, documentIndex int, err error) { + splittedPath := strings.Split(filePathWithIndex, ":") + if len(splittedPath) <= 1 { + return "", 0, fmt.Errorf("expected to find ':' in file path") + } + + filePath = splittedPath[0] + if documentIndex, err := strconv.Atoi(splittedPath[1]); err != nil { + return "", 0, err + } else { + return filePath, documentIndex, nil + } +} + +func (h *FixHandler) applyFixToFile(filePath, yamlExpression string) (cmdError error) { + var completedSuccessfully bool + writeInPlaceHandler := yqlib.NewWriteInPlaceHandler(filePath) + out, err := writeInPlaceHandler.CreateTempFile() + if err != nil { + return fmt.Errorf("unable to create a tmp file for in-place YAML update: %s", err) + } + + defer func() { + if cmdError == nil { + cmdError = writeInPlaceHandler.FinishWriteInPlace(completedSuccessfully) + } + }() + + encoder := yqlib.NewYamlEncoder(2, false, yqlib.ConfiguredYamlPreferences) + + printer := yqlib.NewPrinter(encoder, yqlib.NewSinglePrinterWriter(out)) + allAtOnceEvaluator := yqlib.NewAllAtOnceEvaluator() + + preferences := yqlib.ConfiguredYamlPreferences + preferences.EvaluateTogether = true + decoder := yqlib.NewYamlDecoder(preferences) + + err = allAtOnceEvaluator.EvaluateFiles(yamlExpression, []string{filePath}, printer, decoder) + + completedSuccessfully = err == nil + + return err +} + +func (rfi *ResourceFixInfo) addYamlExpressionsFromResourceAssociatedControl(documentIndex int, ac *resourcesresults.ResourceAssociatedControl, skipUserValues bool) { + for _, rule := range ac.ResourceAssociatedRules { + if !rule.GetStatus(nil).IsFailed() { + continue + } + + for _, rulePaths := range rule.Paths { + if rulePaths.FixPath.Path == "" { + continue + } + if strings.HasPrefix(rulePaths.FixPath.Value, UserValuePrefix) && skipUserValues { + continue + } + + yamlExpression := fixPathToValidYamlExpression(rulePaths.FixPath.Path, rulePaths.FixPath.Value, documentIndex) + rfi.YamlExpressions[yamlExpression] = &rulePaths.FixPath + } + } +} + +// reduceYamlExpressions reduces the number of yaml expressions to a single one +func reduceYamlExpressions(resource *ResourceFixInfo) string { + expressions := make([]string, 0, len(resource.YamlExpressions)) + for expr := range resource.YamlExpressions { + expressions = append(expressions, expr) + } + + return strings.Join(expressions, " | ") +} + +func fixPathToValidYamlExpression(fixPath, value string, documentIndexInYaml int) string { + isStringValue := true + if _, err := strconv.ParseBool(value); err == nil { + isStringValue = false + } else if _, err := strconv.ParseFloat(value, 64); err == nil { + isStringValue = false + } else if _, err := strconv.Atoi(value); err == nil { + isStringValue = false + } + + // Strings should be quoted + if isStringValue { + value = fmt.Sprintf("\"%s\"", value) + } + + // select document index and add a dot for the root node + return fmt.Sprintf("select(di==%d).%s |= %s", documentIndexInYaml, fixPath, value) +} diff --git a/core/pkg/fixhandler/fixhandler_test.go b/core/pkg/fixhandler/fixhandler_test.go new file mode 100644 index 00000000..9073361c --- /dev/null +++ b/core/pkg/fixhandler/fixhandler_test.go @@ -0,0 +1,117 @@ +package fixhandler + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + logger "github.com/kubescape/go-logger" + metav1 "github.com/kubescape/kubescape/v2/core/meta/datastructures/v1" + reporthandlingv2 "github.com/kubescape/opa-utils/reporthandling/v2" + "github.com/mikefarah/yq/v4/pkg/yqlib" + "gopkg.in/op/go-logging.v1" +) + +func NewFixHandlerMock() (*FixHandler, error) { + backendLoggerLeveled := logging.AddModuleLevel(logging.NewLogBackend(logger.L().GetWriter(), "", 0)) + backendLoggerLeveled.SetLevel(logging.ERROR, "") + yqlib.GetLogger().SetBackend(backendLoggerLeveled) + + return &FixHandler{ + fixInfo: &metav1.FixInfo{}, + reportObj: &reporthandlingv2.PostureReport{}, + localBasePath: "", + }, nil +} + +func onlineBoutiquePath() string { + o, _ := os.Getwd() + return filepath.Join(filepath.Dir(o), "..", "..", "examples", "online-boutique") +} + +func TestFixHandler_applyFixToFile(t *testing.T) { + originalFilePath := filepath.Join(onlineBoutiquePath(), "adservice.yaml") + // create temp file + tempFile, err := ioutil.TempFile("", "adservice.yaml") + if err != nil { + panic(err) + } + defer os.Remove(tempFile.Name()) + + // read original file + b, err := ioutil.ReadFile(originalFilePath) + if err != nil { + panic(err) + } + assert.NotContains(t, string(b), "readOnlyRootFilesystem: true") + + // write original file contents to temp file + err = ioutil.WriteFile(tempFile.Name(), b, 0644) + if err != nil { + panic(err) + } + + // make changes to temp file + h, _ := NewFixHandlerMock() + yamlExpression := "select(di==0).spec.template.spec.containers[0].securityContext.readOnlyRootFilesystem |= true" + err = h.applyFixToFile(tempFile.Name(), yamlExpression) + assert.NoError(t, err) + + // Check temp file contents + b, err = ioutil.ReadFile(tempFile.Name()) + if err != nil { + panic(err) + } + assert.Contains(t, string(b), "readOnlyRootFilesystem: true") +} + +func Test_fixPathToValidYamlExpression(t *testing.T) { + type args struct { + fixPath string + value string + documentIndexInYaml int + } + tests := []struct { + name string + args args + want string + }{ + { + name: "fix path with boolean value", + args: args{ + fixPath: "spec.template.spec.containers[0].securityContext.privileged", + value: "true", + documentIndexInYaml: 2, + }, + want: "select(di==2).spec.template.spec.containers[0].securityContext.privileged |= true", + }, + { + name: "fix path with string value", + args: args{ + fixPath: "metadata.namespace", + value: "YOUR_NAMESPACE", + documentIndexInYaml: 0, + }, + want: "select(di==0).metadata.namespace |= \"YOUR_NAMESPACE\"", + }, + { + name: "fix path with number", + args: args{ + fixPath: "xxx.yyy", + value: "123", + documentIndexInYaml: 0, + }, + want: "select(di==0).xxx.yyy |= 123", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := fixPathToValidYamlExpression(tt.args.fixPath, tt.args.value, tt.args.documentIndexInYaml); got != tt.want { + t.Errorf("fixPathToValidYamlExpression() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/core/pkg/resultshandling/printer/v2/utils.go b/core/pkg/resultshandling/printer/v2/utils.go index b6ba3ac7..58e6cd51 100644 --- a/core/pkg/resultshandling/printer/v2/utils.go +++ b/core/pkg/resultshandling/printer/v2/utils.go @@ -14,6 +14,7 @@ import ( func FinalizeResults(data *cautils.OPASessionObj) *reporthandlingv2.PostureReport { report := reporthandlingv2.PostureReport{ SummaryDetails: data.Report.SummaryDetails, + Metadata: *data.Metadata, ClusterAPIServerInfo: data.Report.ClusterAPIServerInfo, ReportGenerationTime: data.Report.ReportGenerationTime, Attributes: data.Report.Attributes,