From 4f466d517a00190643d6b9b4e81a705e828dc999 Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 21 Oct 2021 16:05:51 +0300 Subject: [PATCH 01/70] fixed junit counter --- resultshandling/printer/junit.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resultshandling/printer/junit.go b/resultshandling/printer/junit.go index 370a5d0a..60fe18d8 100644 --- a/resultshandling/printer/junit.go +++ b/resultshandling/printer/junit.go @@ -74,9 +74,9 @@ func convertPostureReportToJunitResult(postureResult *reporthandling.PostureRepo testCase.Time = "0" if 0 < len(controlReports.RuleReports[0].RuleResponses) { - testCase.Resources = framework.GetNumberOfResources() - testCase.Excluded = framework.GetNumberOfWarningResources() - testCase.Failed = framework.GetNumberOfFailedResources() + testCase.Resources = controlReports.GetNumberOfResources() + testCase.Excluded = controlReports.GetNumberOfWarningResources() + testCase.Failed = controlReports.GetNumberOfFailedResources() failure := JUnitFailure{} failure.Message = fmt.Sprintf("%d resources failed", testCase.Failed) for _, ruleResponses := range controlReports.RuleReports[0].RuleResponses { From 0a850e47df0459d6a1331b8903ee9b7c57565af7 Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 24 Oct 2021 17:51:03 +0300 Subject: [PATCH 02/70] use interfaces --- cautils/scaninfo.go | 20 -- clihandler/cmd/control.go | 2 +- clihandler/cmd/framework.go | 2 +- clihandler/initcli.go | 81 +++++- opaprocessor/processorhandler.go | 12 - policyhandler/handlenotification.go | 28 +- .../filesloader.go | 27 +- .../filesloader_test.go | 2 +- .../k8sresources.go | 40 ++- .../k8sresourcesutils.go | 2 +- .../k8sresourcesutils_test.go | 2 +- .../repositoryscanner.go | 2 +- resourcehandler/resourceshandler.go | 13 + .../urlloader.go | 2 +- resultshandling/printer/jsonprinter.go | 29 ++ resultshandling/printer/junit.go | 27 ++ resultshandling/printer/prettyprinter.go | 215 ++++++++++++++ resultshandling/printer/printresults.go | 266 +----------------- resultshandling/printer/silentprinter.go | 11 + resultshandling/reporter/mockreporter.go | 17 ++ .../reporter/reporteventreceiver.go | 37 ++- .../reporter/reporteventreceiverutils.go | 7 +- resultshandling/results.go | 33 ++- 23 files changed, 509 insertions(+), 368 deletions(-) rename {policyhandler => resourcehandler}/filesloader.go (88%) rename {policyhandler => resourcehandler}/filesloader_test.go (98%) rename {policyhandler => resourcehandler}/k8sresources.go (61%) rename {policyhandler => resourcehandler}/k8sresourcesutils.go (98%) rename {policyhandler => resourcehandler}/k8sresourcesutils_test.go (98%) rename {policyhandler => resourcehandler}/repositoryscanner.go (99%) create mode 100644 resourcehandler/resourceshandler.go rename {policyhandler => resourcehandler}/urlloader.go (98%) create mode 100644 resultshandling/printer/jsonprinter.go create mode 100644 resultshandling/printer/prettyprinter.go create mode 100644 resultshandling/printer/silentprinter.go create mode 100644 resultshandling/reporter/mockreporter.go diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index 8ca73ca6..305b400c 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -3,7 +3,6 @@ package cautils import ( "path/filepath" - "github.com/armosec/k8s-interface/k8sinterface" "github.com/armosec/kubescape/cautils/getter" "github.com/armosec/opa-utils/reporthandling" ) @@ -84,22 +83,3 @@ func (scanInfo *ScanInfo) setOutputFile() { func (scanInfo *ScanInfo) ScanRunningCluster() bool { return len(scanInfo.InputPatterns) == 0 } - -func (scanInfo *ScanInfo) SetClusterConfig() (IClusterConfig, *k8sinterface.KubernetesApi) { - var clusterConfig IClusterConfig - var k8s *k8sinterface.KubernetesApi - if !scanInfo.ScanRunningCluster() { - k8sinterface.ConnectedToCluster = false - clusterConfig = NewEmptyConfig() - } else { - k8s = k8sinterface.NewKubernetesApi() - // setup cluster config - clusterConfig = ClusterConfigSetup(scanInfo, k8s, getter.GetArmoAPIConnector()) - } - return clusterConfig, k8s -} - -// func (scanInfo *ScanInfo) ConnectedToCluster(k8s k8sinterface.) bool { -// _, err := k8s.KubernetesClient.CoreV1().Pods("").List(context.TODO(), metav1.ListOptions{}) -// return err == nil -// } diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index 0618e0c4..e89ba905 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -31,7 +31,7 @@ var controlCmd = &cobra.Command{ scanInfo.PolicyIdentifier.Kind = reporthandling.KindControl scanInfo.Init() cautils.SetSilentMode(scanInfo.Silent) - err := clihandler.CliSetup(scanInfo) + err := clihandler.CliSetup(&scanInfo) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index bd0bbfe6..9461dce5 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -54,7 +54,7 @@ var frameworkCmd = &cobra.Command{ } scanInfo.Init() cautils.SetSilentMode(scanInfo.Silent) - err := clihandler.CliSetup(scanInfo) + err := clihandler.CliSetup(&scanInfo) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) diff --git a/clihandler/initcli.go b/clihandler/initcli.go index c335702d..9dd35a85 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -6,9 +6,12 @@ import ( "strings" "github.com/armosec/armoapi-go/armotypes" + "github.com/armosec/k8s-interface/k8sinterface" "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/cautils/getter" "github.com/armosec/kubescape/opaprocessor" "github.com/armosec/kubescape/policyhandler" + "github.com/armosec/kubescape/resourcehandler" "github.com/armosec/kubescape/resultshandling" "github.com/armosec/kubescape/resultshandling/printer" "github.com/armosec/kubescape/resultshandling/reporter" @@ -23,25 +26,77 @@ type CLIHandler struct { var SupportedFrameworks = []string{"nsa", "mitre"} var ValidFrameworks = strings.Join(SupportedFrameworks, ", ") -func CliSetup(scanInfo cautils.ScanInfo) error { +type componentInterfaces struct { + clusterConfig cautils.IClusterConfig + resourceHandler resourcehandler.IResourceHandler + report reporter.IReport + printerHandler printer.IPrinter +} - clusterConfig, k8s := scanInfo.SetClusterConfig() +func getReporter(scanInfo *cautils.ScanInfo) reporter.IReport { + if scanInfo.Local { + return reporter.NewReportMock() + } + if !scanInfo.FrameworkScan { + return reporter.NewReportMock() + } + + return reporter.NewReportEventReceiver() +} +func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { + var resourceHandler resourcehandler.IResourceHandler + var clusterConfig cautils.IClusterConfig + var reportHandler reporter.IReport + + if !scanInfo.ScanRunningCluster() { + k8sinterface.ConnectedToCluster = false + clusterConfig = cautils.NewEmptyConfig() + + // load fom file + resourceHandler = resourcehandler.NewFileResourceHandler(scanInfo.InputPatterns) + + // set mock report (do not send report) + reportHandler = reporter.NewReportMock() + } else { + k8s := k8sinterface.NewKubernetesApi() + resourceHandler = resourcehandler.NewK8sResourceHandler(k8s, scanInfo.ExcludedNamespaces) + clusterConfig = cautils.ClusterConfigSetup(scanInfo, k8s, getter.GetArmoAPIConnector()) + + // setup reporter + reportHandler = getReporter(scanInfo) + } + + // setup printer + printerHandler := printer.GetPrinter(scanInfo.Format) + printerHandler.SetWriter(scanInfo.Output) + + return componentInterfaces{ + clusterConfig: clusterConfig, + resourceHandler: resourceHandler, + report: reportHandler, + printerHandler: printerHandler, + } +} + +func CliSetup(scanInfo *cautils.ScanInfo) error { + + interfaces := getInterfaces(scanInfo) processNotification := make(chan *cautils.OPASessionObj) reportResults := make(chan *cautils.OPASessionObj) - // policy handler setup - policyHandler := policyhandler.NewPolicyHandler(&processNotification, k8s) - - if err := clusterConfig.SetConfig(scanInfo.Account); err != nil { + if err := interfaces.clusterConfig.SetConfig(scanInfo.Account); err != nil { fmt.Println(err) } - cautils.ClusterName = clusterConfig.GetClusterName() - cautils.CustomerGUID = clusterConfig.GetCustomerGUID() - + cautils.ClusterName = interfaces.clusterConfig.GetClusterName() // TODO - Deprecated + cautils.CustomerGUID = interfaces.clusterConfig.GetCustomerGUID() // TODO - Deprecated + interfaces.report.SetClusterName(interfaces.clusterConfig.GetClusterName()) + interfaces.report.SetCustomerGUID(interfaces.clusterConfig.GetCustomerGUID()) // cli handler setup go func() { + // policy handler setup + policyHandler := policyhandler.NewPolicyHandler(&processNotification, interfaces.resourceHandler) cli := NewCLIHandler(policyHandler, scanInfo) if err := cli.Scan(); err != nil { fmt.Println(err) @@ -55,12 +110,12 @@ func CliSetup(scanInfo cautils.ScanInfo) error { opaprocessorObj.ProcessRulesListenner() }() - resultsHandling := resultshandling.NewResultsHandler(&reportResults, reporter.NewReportEventReceiver(), printer.NewPrinter(scanInfo.Format, scanInfo.Output)) + resultsHandling := resultshandling.NewResultsHandler(&reportResults, interfaces.report, interfaces.printerHandler) score := resultsHandling.HandleResults(scanInfo) // print report url if scanInfo.FrameworkScan { - clusterConfig.GenerateURL() + interfaces.clusterConfig.GenerateURL() } adjustedFailThreshold := float32(scanInfo.FailThreshold) / 100 @@ -71,9 +126,9 @@ func CliSetup(scanInfo cautils.ScanInfo) error { return nil } -func NewCLIHandler(policyHandler *policyhandler.PolicyHandler, scanInfo cautils.ScanInfo) *CLIHandler { +func NewCLIHandler(policyHandler *policyhandler.PolicyHandler, scanInfo *cautils.ScanInfo) *CLIHandler { return &CLIHandler{ - scanInfo: &scanInfo, + scanInfo: scanInfo, policyHandler: policyHandler, } } diff --git a/opaprocessor/processorhandler.go b/opaprocessor/processorhandler.go index ab79456e..d4a6b759 100644 --- a/opaprocessor/processorhandler.go +++ b/opaprocessor/processorhandler.go @@ -8,7 +8,6 @@ import ( "github.com/armosec/kubescape/cautils" "github.com/armosec/opa-utils/exceptions" "github.com/armosec/opa-utils/reporthandling" - "github.com/armosec/opa-utils/score" "github.com/armosec/k8s-interface/k8sinterface" @@ -226,17 +225,6 @@ func (opap *OPAProcessor) regoEval(inputObj []map[string]interface{}, compiledRe return results, nil } -func (opap *OPAProcessor) updateScore() { - - if !k8sinterface.ConnectedToCluster { - return - } - - // calculate score - s := score.NewScore(k8sinterface.NewKubernetesApi(), ScoreConfigPath) - s.Calculate(opap.PostureReport.FrameworkReports) -} - func (opap *OPAProcessor) updateResults() { for f := range opap.PostureReport.FrameworkReports { // set exceptions diff --git a/policyhandler/handlenotification.go b/policyhandler/handlenotification.go index 76ecbb6e..3a3cfdd5 100644 --- a/policyhandler/handlenotification.go +++ b/policyhandler/handlenotification.go @@ -4,30 +4,25 @@ import ( "fmt" "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/resourcehandler" "github.com/armosec/opa-utils/reporthandling" "github.com/armosec/armoapi-go/armotypes" - "github.com/armosec/k8s-interface/k8sinterface" ) -var supportedFrameworks = []reporthandling.PolicyIdentifier{ - {Kind: "Framework", Name: "nsa"}, - {Kind: "Framework", Name: "mitre"}, -} - // PolicyHandler - type PolicyHandler struct { - k8s *k8sinterface.KubernetesApi + resourceHandler resourcehandler.IResourceHandler // we are listening on this chan in opaprocessor/processorhandler.go/ProcessRulesListenner func processPolicy *chan *cautils.OPASessionObj getters *cautils.Getters } // CreatePolicyHandler Create ws-handler obj -func NewPolicyHandler(processPolicy *chan *cautils.OPASessionObj, k8s *k8sinterface.KubernetesApi) *PolicyHandler { +func NewPolicyHandler(processPolicy *chan *cautils.OPASessionObj, resourceHandler resourcehandler.IResourceHandler) *PolicyHandler { return &PolicyHandler{ - k8s: k8s, - processPolicy: processPolicy, + resourceHandler: resourceHandler, + processPolicy: processPolicy, } } @@ -82,16 +77,7 @@ func (policyHandler *PolicyHandler) getPolicies(notification *reporthandling.Pol } func (policyHandler *PolicyHandler) getResources(notification *reporthandling.PolicyNotification, opaSessionObj *cautils.OPASessionObj, scanInfo *cautils.ScanInfo) (*cautils.K8SResources, error) { - var k8sResources *cautils.K8SResources - var err error - if k8sinterface.ConnectedToCluster { // TODO - use interface - if opaSessionObj.PostureReport.ClusterAPIServerInfo, err = policyHandler.k8s.KubernetesClient.Discovery().ServerVersion(); err != nil { - cautils.ErrorDisplay(fmt.Sprintf("Failed to discover API server inforamtion: %v", err)) - } - k8sResources, err = policyHandler.getK8sResources(opaSessionObj.Frameworks, ¬ification.Designators, scanInfo.ExcludedNamespaces) - } else { - k8sResources, err = policyHandler.loadResources(opaSessionObj.Frameworks, scanInfo) - } - return k8sResources, err + opaSessionObj.PostureReport.ClusterAPIServerInfo = policyHandler.resourceHandler.GetClusterAPIServerInfo() + return policyHandler.resourceHandler.GetResources(opaSessionObj.Frameworks, ¬ification.Designators) } diff --git a/policyhandler/filesloader.go b/resourcehandler/filesloader.go similarity index 88% rename from policyhandler/filesloader.go rename to resourcehandler/filesloader.go index 60f40f5a..eabb9109 100644 --- a/policyhandler/filesloader.go +++ b/resourcehandler/filesloader.go @@ -1,4 +1,4 @@ -package policyhandler +package resourcehandler import ( "bytes" @@ -8,7 +8,9 @@ import ( "path/filepath" "strings" + "github.com/armosec/armoapi-go/armotypes" "github.com/armosec/k8s-interface/workloadinterface" + "k8s.io/apimachinery/pkg/version" "github.com/armosec/k8s-interface/k8sinterface" "github.com/armosec/kubescape/cautils" @@ -29,11 +31,22 @@ const ( JSON_FILE_FORMAT FileFormat = "json" ) -func (policyHandler *PolicyHandler) loadResources(frameworks []reporthandling.Framework, scanInfo *cautils.ScanInfo) (*cautils.K8SResources, error) { +// FileResourceHandler handle resources from files and URLs +type FileResourceHandler struct { + inputPatterns []string +} + +func NewFileResourceHandler(inputPatterns []string) *FileResourceHandler { + return &FileResourceHandler{ + inputPatterns: inputPatterns, + } +} + +func (fileHandler *FileResourceHandler) GetResources(frameworks []reporthandling.Framework, designator *armotypes.PortalDesignator) (*cautils.K8SResources, error) { workloads := []k8sinterface.IWorkload{} // load resource from local file system - w, err := loadResourcesFromFiles(scanInfo.InputPatterns) + w, err := loadResourcesFromFiles(fileHandler.inputPatterns) if err != nil { return nil, err } @@ -42,7 +55,7 @@ func (policyHandler *PolicyHandler) loadResources(frameworks []reporthandling.Fr } // load resources from url - w, err = loadResourcesFromUrl(scanInfo.InputPatterns) + w, err = loadResourcesFromUrl(fileHandler.inputPatterns) if err != nil { return nil, err } @@ -59,7 +72,7 @@ func (policyHandler *PolicyHandler) loadResources(frameworks []reporthandling.Fr // build resources map // map resources based on framework required resources: map["/group/version/kind"][] - k8sResources := setResourceMap(frameworks) + k8sResources := setResourceMap(frameworks) // TODO - support designators // save only relevant resources for i := range allResources { @@ -72,6 +85,10 @@ func (policyHandler *PolicyHandler) loadResources(frameworks []reporthandling.Fr } +func (fileHandler *FileResourceHandler) GetClusterAPIServerInfo() *version.Info { + return nil +} + func loadResourcesFromFiles(inputPatterns []string) ([]k8sinterface.IWorkload, error) { files, errs := listFiles(inputPatterns) if len(errs) > 0 { diff --git a/policyhandler/filesloader_test.go b/resourcehandler/filesloader_test.go similarity index 98% rename from policyhandler/filesloader_test.go rename to resourcehandler/filesloader_test.go index f2bea56b..04d64082 100644 --- a/policyhandler/filesloader_test.go +++ b/resourcehandler/filesloader_test.go @@ -1,4 +1,4 @@ -package policyhandler +package resourcehandler import ( "fmt" diff --git a/policyhandler/k8sresources.go b/resourcehandler/k8sresources.go similarity index 61% rename from policyhandler/k8sresources.go rename to resourcehandler/k8sresources.go index 28f17577..74bc4922 100644 --- a/policyhandler/k8sresources.go +++ b/resourcehandler/k8sresources.go @@ -1,6 +1,7 @@ -package policyhandler +package resourcehandler import ( + "context" "fmt" "strings" @@ -15,12 +16,23 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8slabels "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/dynamic" ) -const SelectAllResources = "*" +type K8sResourceHandler struct { + k8s *k8sinterface.KubernetesApi + excludedNamespaces string // excluded namespaces (separated by comma) +} -func (policyHandler *PolicyHandler) getK8sResources(frameworks []reporthandling.Framework, designator *armotypes.PortalDesignator, excludedNamespaces string) (*cautils.K8SResources, error) { +func NewK8sResourceHandler(k8s *k8sinterface.KubernetesApi, excludedNamespaces string) *K8sResourceHandler { + return &K8sResourceHandler{ + k8s: k8s, + excludedNamespaces: excludedNamespaces, + } +} + +func (k8sHandler *K8sResourceHandler) GetResources(frameworks []reporthandling.Framework, designator *armotypes.PortalDesignator) (*cautils.K8SResources, error) { // get k8s resources cautils.ProgressTextDisplay("Accessing Kubernetes objects") @@ -31,7 +43,7 @@ func (policyHandler *PolicyHandler) getK8sResources(frameworks []reporthandling. _, namespace, labels := armotypes.DigestPortalDesignator(designator) // pull k8s recourses - if err := policyHandler.pullResources(k8sResourcesMap, namespace, labels, excludedNamespaces); err != nil { + if err := k8sHandler.pullResources(k8sResourcesMap, namespace, labels, k8sHandler.excludedNamespaces); err != nil { return k8sResourcesMap, err } @@ -39,13 +51,21 @@ func (policyHandler *PolicyHandler) getK8sResources(frameworks []reporthandling. return k8sResourcesMap, nil } -func (policyHandler *PolicyHandler) pullResources(k8sResources *cautils.K8SResources, namespace string, labels map[string]string, excludedNamespaces string) error { +func (k8sHandler *K8sResourceHandler) GetClusterAPIServerInfo() *version.Info { + clusterAPIServerInfo, err := k8sHandler.k8s.KubernetesClient.Discovery().ServerVersion() + if err != nil { + cautils.ErrorDisplay(fmt.Sprintf("Failed to discover API server information: %v", err)) + return nil + } + return clusterAPIServerInfo +} +func (k8sHandler *K8sResourceHandler) pullResources(k8sResources *cautils.K8SResources, namespace string, labels map[string]string, excludedNamespaces string) error { var errs error for groupResource := range *k8sResources { apiGroup, apiVersion, resource := k8sinterface.StringToResourceGroup(groupResource) gvr := schema.GroupVersionResource{Group: apiGroup, Version: apiVersion, Resource: resource} - result, err := policyHandler.pullSingleResource(&gvr, namespace, labels, excludedNamespaces) + result, err := k8sHandler.pullSingleResource(&gvr, namespace, labels, excludedNamespaces) if err != nil { // handle error if errs == nil { @@ -61,7 +81,7 @@ func (policyHandler *PolicyHandler) pullResources(k8sResources *cautils.K8SResou return errs } -func (policyHandler *PolicyHandler) pullSingleResource(resource *schema.GroupVersionResource, namespace string, labels map[string]string, excludedNamespaces string) ([]unstructured.Unstructured, error) { +func (k8sHandler *K8sResourceHandler) pullSingleResource(resource *schema.GroupVersionResource, namespace string, labels map[string]string, excludedNamespaces string) ([]unstructured.Unstructured, error) { // set labels listOptions := metav1.ListOptions{} @@ -76,13 +96,13 @@ func (policyHandler *PolicyHandler) pullSingleResource(resource *schema.GroupVer // set dynamic object var clientResource dynamic.ResourceInterface if namespace != "" && k8sinterface.IsNamespaceScope(resource.Group, resource.Resource) { - clientResource = policyHandler.k8s.DynamicClient.Resource(*resource).Namespace(namespace) + clientResource = k8sHandler.k8s.DynamicClient.Resource(*resource).Namespace(namespace) } else { - clientResource = policyHandler.k8s.DynamicClient.Resource(*resource) + clientResource = k8sHandler.k8s.DynamicClient.Resource(*resource) } // list resources - result, err := clientResource.List(policyHandler.k8s.Context, listOptions) + result, err := clientResource.List(context.Background(), listOptions) if err != nil { return nil, fmt.Errorf("failed to get resource: %v, namespace: %s, labelSelector: %v, reason: %s", resource, namespace, listOptions.LabelSelector, err.Error()) } diff --git a/policyhandler/k8sresourcesutils.go b/resourcehandler/k8sresourcesutils.go similarity index 98% rename from policyhandler/k8sresourcesutils.go rename to resourcehandler/k8sresourcesutils.go index 48932a42..2c14b05b 100644 --- a/policyhandler/k8sresourcesutils.go +++ b/resourcehandler/k8sresourcesutils.go @@ -1,4 +1,4 @@ -package policyhandler +package resourcehandler import ( "github.com/armosec/kubescape/cautils" diff --git a/policyhandler/k8sresourcesutils_test.go b/resourcehandler/k8sresourcesutils_test.go similarity index 98% rename from policyhandler/k8sresourcesutils_test.go rename to resourcehandler/k8sresourcesutils_test.go index 63239047..60f38936 100644 --- a/policyhandler/k8sresourcesutils_test.go +++ b/resourcehandler/k8sresourcesutils_test.go @@ -1,4 +1,4 @@ -package policyhandler +package resourcehandler import ( "github.com/armosec/k8s-interface/k8sinterface" diff --git a/policyhandler/repositoryscanner.go b/resourcehandler/repositoryscanner.go similarity index 99% rename from policyhandler/repositoryscanner.go rename to resourcehandler/repositoryscanner.go index a568e8c5..7b44017d 100644 --- a/policyhandler/repositoryscanner.go +++ b/resourcehandler/repositoryscanner.go @@ -1,4 +1,4 @@ -package policyhandler +package resourcehandler import ( "encoding/json" diff --git a/resourcehandler/resourceshandler.go b/resourcehandler/resourceshandler.go new file mode 100644 index 00000000..c2ddf15f --- /dev/null +++ b/resourcehandler/resourceshandler.go @@ -0,0 +1,13 @@ +package resourcehandler + +import ( + "github.com/armosec/armoapi-go/armotypes" + "github.com/armosec/kubescape/cautils" + "github.com/armosec/opa-utils/reporthandling" + "k8s.io/apimachinery/pkg/version" +) + +type IResourceHandler interface { + GetResources(frameworks []reporthandling.Framework, designator *armotypes.PortalDesignator) (*cautils.K8SResources, error) + GetClusterAPIServerInfo() *version.Info +} diff --git a/policyhandler/urlloader.go b/resourcehandler/urlloader.go similarity index 98% rename from policyhandler/urlloader.go rename to resourcehandler/urlloader.go index faa8f634..96fbdc0d 100644 --- a/policyhandler/urlloader.go +++ b/resourcehandler/urlloader.go @@ -1,4 +1,4 @@ -package policyhandler +package resourcehandler import ( "bytes" diff --git a/resultshandling/printer/jsonprinter.go b/resultshandling/printer/jsonprinter.go new file mode 100644 index 00000000..5a4a0f5b --- /dev/null +++ b/resultshandling/printer/jsonprinter.go @@ -0,0 +1,29 @@ +package printer + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/armosec/kubescape/cautils" +) + +type JsonPrinter struct { + writer *os.File +} + +func NewJsonPrinter() *JsonPrinter { + return &JsonPrinter{} +} + +func (jsonPrinter *JsonPrinter) SetWriter(outputFile string) { + jsonPrinter.writer = getWriter(outputFile) +} +func (jsonPrinter *JsonPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { + postureReportStr, err := json.Marshal(opaSessionObj.PostureReport.FrameworkReports[0]) + if err != nil { + fmt.Println("Failed to convert posture report object!") + os.Exit(1) + } + jsonPrinter.writer.Write(postureReportStr) +} diff --git a/resultshandling/printer/junit.go b/resultshandling/printer/junit.go index 370a5d0a..cfa57003 100644 --- a/resultshandling/printer/junit.go +++ b/resultshandling/printer/junit.go @@ -3,10 +3,37 @@ package printer import ( "encoding/xml" "fmt" + "os" + "github.com/armosec/kubescape/cautils" "github.com/armosec/opa-utils/reporthandling" ) +type JunitPrinter struct { + writer *os.File +} + +func NewJunitPrinter() *JunitPrinter { + return &JunitPrinter{} +} + +func (junitPrinter *JunitPrinter) SetWriter(outputFile string) { + junitPrinter.writer = getWriter(outputFile) +} +func (junitPrinter *JunitPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { + junitResult, err := convertPostureReportToJunitResult(opaSessionObj.PostureReport) + if err != nil { + fmt.Println("Failed to convert posture report object!") + os.Exit(1) + } + postureReportStr, err := xml.Marshal(junitResult) + if err != nil { + fmt.Println("Failed to convert posture report object!") + os.Exit(1) + } + junitPrinter.writer.Write(postureReportStr) +} + type JUnitTestSuites struct { XMLName xml.Name `xml:"testsuites"` Suites []JUnitTestSuite `xml:"testsuite"` diff --git a/resultshandling/printer/prettyprinter.go b/resultshandling/printer/prettyprinter.go new file mode 100644 index 00000000..8b814fa9 --- /dev/null +++ b/resultshandling/printer/prettyprinter.go @@ -0,0 +1,215 @@ +package printer + +import ( + "fmt" + "os" + "sort" + + "github.com/armosec/kubescape/cautils" + "github.com/armosec/opa-utils/reporthandling" + "github.com/enescakir/emoji" + "github.com/olekukonko/tablewriter" +) + +type PrettyPrinter struct { + writer *os.File + summary Summary + sortedControlNames []string + frameworkSummary ControlSummary +} + +func NewPrettyPrinter() *PrettyPrinter { + return &PrettyPrinter{ + summary: NewSummary(), + } +} + +func (printer *PrettyPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { + // score := calculatePostureScore(opaSessionObj.PostureReport) + + printer.summarySetup(opaSessionObj.PostureReport) + printer.printResults() + printer.printSummaryTable() + + // return score +} + +func (printer *PrettyPrinter) SetWriter(outputFile string) { + printer.writer = getWriter(outputFile) +} +func (printer *PrettyPrinter) summarySetup(postureReport *reporthandling.PostureReport) { + for _, fr := range postureReport.FrameworkReports { + printer.frameworkSummary = ControlSummary{ + TotalResources: fr.GetNumberOfResources(), + TotalFailed: fr.GetNumberOfFailedResources(), + TotalWarnign: fr.GetNumberOfWarningResources(), + } + for _, cr := range fr.ControlReports { + if len(cr.RuleReports) == 0 { + continue + } + workloadsSummary := listResultSummary(cr.RuleReports) + + printer.summary[cr.Name] = ControlSummary{ + TotalResources: cr.GetNumberOfResources(), + TotalFailed: cr.GetNumberOfFailedResources(), + TotalWarnign: cr.GetNumberOfWarningResources(), + FailedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryFailed), + ExcludedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryExclude), + Description: cr.Description, + Remediation: cr.Remediation, + ListInputKinds: cr.ListControlsInputKinds(), + } + } + } + printer.sortedControlNames = printer.getSortedControlsNames() +} +func (printer *PrettyPrinter) printResults() { + for i := 0; i < len(printer.sortedControlNames); i++ { + controlSummary := printer.summary[printer.sortedControlNames[i]] + printer.printTitle(printer.sortedControlNames[i], &controlSummary) + printer.printResources(&controlSummary) + if printer.summary[printer.sortedControlNames[i]].TotalResources > 0 { + printer.printSummary(printer.sortedControlNames[i], &controlSummary) + } + + } +} + +func (printer *PrettyPrinter) printSummary(controlName string, controlSummary *ControlSummary) { + cautils.SimpleDisplay(printer.writer, "Summary - ") + cautils.SuccessDisplay(printer.writer, "Passed:%v ", controlSummary.TotalResources-controlSummary.TotalFailed-controlSummary.TotalWarnign) + cautils.WarningDisplay(printer.writer, "Excluded:%v ", controlSummary.TotalWarnign) + cautils.FailureDisplay(printer.writer, "Failed:%v ", controlSummary.TotalFailed) + cautils.InfoDisplay(printer.writer, "Total:%v\n", controlSummary.TotalResources) + if controlSummary.TotalFailed > 0 { + cautils.DescriptionDisplay(printer.writer, "Remediation: %v\n", controlSummary.Remediation) + } + cautils.DescriptionDisplay(printer.writer, "\n") + +} + +func (printer *PrettyPrinter) printTitle(controlName string, controlSummary *ControlSummary) { + cautils.InfoDisplay(printer.writer, "[control: %s] ", controlName) + if controlSummary.TotalResources == 0 { + cautils.InfoDisplay(printer.writer, "resources not found %v\n", emoji.ConfusedFace) + } else if controlSummary.TotalFailed != 0 { + cautils.FailureDisplay(printer.writer, "failed %v\n", emoji.SadButRelievedFace) + } else if controlSummary.TotalWarnign != 0 { + cautils.WarningDisplay(printer.writer, "excluded %v\n", emoji.NeutralFace) + } else { + cautils.SuccessDisplay(printer.writer, "passed %v\n", emoji.ThumbsUp) + } + + cautils.DescriptionDisplay(printer.writer, "Description: %s\n", controlSummary.Description) + +} +func (printer *PrettyPrinter) printResources(controlSummary *ControlSummary) { + + if len(controlSummary.FailedWorkloads) > 0 { + cautils.FailureDisplay(printer.writer, "Failed:\n") + printer.printGroupedResources(controlSummary.FailedWorkloads) + } + if len(controlSummary.ExcludedWorkloads) > 0 { + cautils.WarningDisplay(printer.writer, "Excluded:\n") + printer.printGroupedResources(controlSummary.ExcludedWorkloads) + } + +} + +func (printer *PrettyPrinter) printGroupedResources(workloads map[string][]WorkloadSummary) { + + indent := INDENT + + for ns, rsc := range workloads { + preIndent := indent + if ns != "" { + cautils.SimpleDisplay(printer.writer, "%sNamespace %s\n", indent, ns) + } + preIndent2 := indent + for r := range rsc { + indent += indent + cautils.SimpleDisplay(printer.writer, fmt.Sprintf("%s%s - %s\n", indent, rsc[r].Kind, rsc[r].Name)) + indent = preIndent2 + } + indent = preIndent + } + +} + +func generateRow(control string, cs ControlSummary) []string { + row := []string{control} + row = append(row, cs.ToSlice()...) + if cs.TotalResources != 0 { + row = append(row, fmt.Sprintf("%d%s", percentage(cs.TotalResources, cs.TotalFailed), "%")) + } else { + row = append(row, EmptyPercentage) + } + return row +} + +func generateHeader() []string { + return []string{"Control Name", "Failed Resources", "Excluded Resources", "All Resources", "% success"} +} + +func percentage(big, small int) int { + if big == 0 { + if small == 0 { + return 100 + } + return 0 + } + return int(float64(float64(big-small)/float64(big)) * 100) +} +func generateFooter(numControlers, sumFailed, sumWarning, sumTotal int) []string { + // Control name | # failed resources | all resources | % success + row := []string{} + row = append(row, "Resource Summary") //fmt.Sprintf(""%d", numControlers")) + row = append(row, fmt.Sprintf("%d", sumFailed)) + row = append(row, fmt.Sprintf("%d", sumWarning)) + row = append(row, fmt.Sprintf("%d", sumTotal)) + if sumTotal != 0 { + row = append(row, fmt.Sprintf("%d%s", percentage(sumTotal, sumFailed), "%")) + } else { + row = append(row, EmptyPercentage) + } + return row +} +func (printer *PrettyPrinter) printSummaryTable() { + summaryTable := tablewriter.NewWriter(printer.writer) + summaryTable.SetAutoWrapText(false) + summaryTable.SetHeader(generateHeader()) + summaryTable.SetHeaderLine(true) + alignments := []int{tablewriter.ALIGN_LEFT, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER} + summaryTable.SetColumnAlignment(alignments) + + for i := 0; i < len(printer.sortedControlNames); i++ { + controlSummary := printer.summary[printer.sortedControlNames[i]] + summaryTable.Append(generateRow(printer.sortedControlNames[i], controlSummary)) + } + summaryTable.SetFooter(generateFooter(len(printer.summary), printer.frameworkSummary.TotalFailed, printer.frameworkSummary.TotalWarnign, printer.frameworkSummary.TotalResources)) + summaryTable.Render() +} + +func (printer *PrettyPrinter) getSortedControlsNames() []string { + controlNames := make([]string, 0, len(printer.summary)) + for k := range printer.summary { + controlNames = append(controlNames, k) + } + sort.Strings(controlNames) + return controlNames +} + +func getWriter(outputFile string) *os.File { + os.Remove(outputFile) + if outputFile != "" { + f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + fmt.Println("Error opening file") + return os.Stdout + } + return f + } + return os.Stdout + +} diff --git a/resultshandling/printer/printresults.go b/resultshandling/printer/printresults.go index 50a624c4..dcf4d4d7 100644 --- a/resultshandling/printer/printresults.go +++ b/resultshandling/printer/printresults.go @@ -1,17 +1,7 @@ package printer import ( - "encoding/json" - "encoding/xml" - "fmt" - "os" - "sort" - "github.com/armosec/kubescape/cautils" - "github.com/armosec/opa-utils/reporthandling" - - "github.com/enescakir/emoji" - "github.com/olekukonko/tablewriter" ) var INDENT = " " @@ -19,253 +9,23 @@ var INDENT = " " const EmptyPercentage = "NaN" const ( - PrettyPrinter string = "pretty-printer" - JsonPrinter string = "json" + PrettyFormat string = "pretty-printer" + JsonFormat string = "json" JunitResultPrinter string = "junit" ) -type Printer struct { - writer *os.File - summary Summary - sortedControlNames []string - printerType string - frameworkSummary ControlSummary +type IPrinter interface { + ActionPrint(opaSessionObj *cautils.OPASessionObj) + SetWriter(outputFile string) } -func NewPrinter(printerType, outputFile string) *Printer { - return &Printer{ - summary: NewSummary(), - writer: getWriter(outputFile), - printerType: printerType, +func GetPrinter(printFormat string) IPrinter { + switch printFormat { + case JsonFormat: + return NewJsonPrinter() + case JunitResultPrinter: + return NewJunitPrinter() + default: + return NewPrettyPrinter() } } - -func calculatePostureScore(postureReport *reporthandling.PostureReport) float32 { - totalResources := 0 - totalFailed := 0 - for _, frameworkReport := range postureReport.FrameworkReports { - totalFailed += frameworkReport.GetNumberOfFailedResources() - totalResources += frameworkReport.GetNumberOfResources() - } - if totalResources == 0 { - return float32(0) - } - return (float32(totalResources) - float32(totalFailed)) / float32(totalResources) -} - -func (printer *Printer) ActionPrint(opaSessionObj *cautils.OPASessionObj) float32 { - score := calculatePostureScore(opaSessionObj.PostureReport) - - if printer.printerType == PrettyPrinter { - printer.SummarySetup(opaSessionObj.PostureReport) - printer.PrintResults() - printer.PrintSummaryTable() - } else if printer.printerType == JsonPrinter { - postureReportStr, err := json.Marshal(opaSessionObj.PostureReport.FrameworkReports[0]) - if err != nil { - fmt.Println("Failed to convert posture report object!") - os.Exit(1) - } - printer.writer.Write(postureReportStr) - fmt.Printf("\nFinal score: %d\n", int(score*100)) - } else if printer.printerType == JunitResultPrinter { - junitResult, err := convertPostureReportToJunitResult(opaSessionObj.PostureReport) - if err != nil { - fmt.Println("Failed to convert posture report object!") - os.Exit(1) - } - postureReportStr, err := xml.Marshal(junitResult) - if err != nil { - fmt.Println("Failed to convert posture report object!") - os.Exit(1) - } - printer.writer.Write(postureReportStr) - fmt.Printf("\nFinal score: %d\n", int(score*100)) - } else if !cautils.IsSilent() { - fmt.Println("unknown output printer") - os.Exit(1) - } - - return score -} - -func (printer *Printer) SummarySetup(postureReport *reporthandling.PostureReport) { - for _, fr := range postureReport.FrameworkReports { - printer.frameworkSummary = ControlSummary{ - TotalResources: fr.GetNumberOfResources(), - TotalFailed: fr.GetNumberOfFailedResources(), - TotalWarnign: fr.GetNumberOfWarningResources(), - } - for _, cr := range fr.ControlReports { - if len(cr.RuleReports) == 0 { - continue - } - workloadsSummary := listResultSummary(cr.RuleReports) - - printer.summary[cr.Name] = ControlSummary{ - TotalResources: cr.GetNumberOfResources(), - TotalFailed: cr.GetNumberOfFailedResources(), - TotalWarnign: cr.GetNumberOfWarningResources(), - FailedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryFailed), - ExcludedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryExclude), - Description: cr.Description, - Remediation: cr.Remediation, - ListInputKinds: cr.ListControlsInputKinds(), - } - } - } - printer.sortedControlNames = printer.getSortedControlsNames() -} -func (printer *Printer) PrintResults() { - for i := 0; i < len(printer.sortedControlNames); i++ { - controlSummary := printer.summary[printer.sortedControlNames[i]] - printer.printTitle(printer.sortedControlNames[i], &controlSummary) - printer.printResources(&controlSummary) - if printer.summary[printer.sortedControlNames[i]].TotalResources > 0 { - printer.printSummary(printer.sortedControlNames[i], &controlSummary) - } - - } -} - -func (printer *Printer) printSummary(controlName string, controlSummary *ControlSummary) { - cautils.SimpleDisplay(printer.writer, "Summary - ") - cautils.SuccessDisplay(printer.writer, "Passed:%v ", controlSummary.TotalResources-controlSummary.TotalFailed-controlSummary.TotalWarnign) - cautils.WarningDisplay(printer.writer, "Excluded:%v ", controlSummary.TotalWarnign) - cautils.FailureDisplay(printer.writer, "Failed:%v ", controlSummary.TotalFailed) - cautils.InfoDisplay(printer.writer, "Total:%v\n", controlSummary.TotalResources) - if controlSummary.TotalFailed > 0 { - cautils.DescriptionDisplay(printer.writer, "Remediation: %v\n", controlSummary.Remediation) - } - cautils.DescriptionDisplay(printer.writer, "\n") - -} - -func (printer *Printer) printTitle(controlName string, controlSummary *ControlSummary) { - cautils.InfoDisplay(printer.writer, "[control: %s] ", controlName) - if controlSummary.TotalResources == 0 { - cautils.InfoDisplay(printer.writer, "resources not found %v\n", emoji.ConfusedFace) - } else if controlSummary.TotalFailed != 0 { - cautils.FailureDisplay(printer.writer, "failed %v\n", emoji.SadButRelievedFace) - } else if controlSummary.TotalWarnign != 0 { - cautils.WarningDisplay(printer.writer, "excluded %v\n", emoji.NeutralFace) - } else { - cautils.SuccessDisplay(printer.writer, "passed %v\n", emoji.ThumbsUp) - } - - cautils.DescriptionDisplay(printer.writer, "Description: %s\n", controlSummary.Description) - -} -func (printer *Printer) printResources(controlSummary *ControlSummary) { - - if len(controlSummary.FailedWorkloads) > 0 { - cautils.FailureDisplay(printer.writer, "Failed:\n") - printer.printGroupedResources(controlSummary.FailedWorkloads) - } - if len(controlSummary.ExcludedWorkloads) > 0 { - cautils.WarningDisplay(printer.writer, "Excluded:\n") - printer.printGroupedResources(controlSummary.ExcludedWorkloads) - } - -} - -func (printer *Printer) printGroupedResources(workloads map[string][]WorkloadSummary) { - - indent := INDENT - - for ns, rsc := range workloads { - preIndent := indent - if ns != "" { - cautils.SimpleDisplay(printer.writer, "%sNamespace %s\n", indent, ns) - } - preIndent2 := indent - for r := range rsc { - indent += indent - cautils.SimpleDisplay(printer.writer, fmt.Sprintf("%s%s - %s\n", indent, rsc[r].Kind, rsc[r].Name)) - indent = preIndent2 - } - indent = preIndent - } - -} - -func (printer *Printer) PrintUrl(url string) { - cautils.InfoTextDisplay(printer.writer, url) -} - -func generateRow(control string, cs ControlSummary) []string { - row := []string{control} - row = append(row, cs.ToSlice()...) - if cs.TotalResources != 0 { - row = append(row, fmt.Sprintf("%d%s", percentage(cs.TotalResources, cs.TotalFailed), "%")) - } else { - row = append(row, EmptyPercentage) - } - return row -} - -func generateHeader() []string { - return []string{"Control Name", "Failed Resources", "Excluded Resources", "All Resources", "% success"} -} - -func percentage(big, small int) int { - if big == 0 { - if small == 0 { - return 100 - } - return 0 - } - return int(float64(float64(big-small)/float64(big)) * 100) -} -func generateFooter(numControlers, sumFailed, sumWarning, sumTotal int) []string { - // Control name | # failed resources | all resources | % success - row := []string{} - row = append(row, "Resource Summary") //fmt.Sprintf(""%d", numControlers")) - row = append(row, fmt.Sprintf("%d", sumFailed)) - row = append(row, fmt.Sprintf("%d", sumWarning)) - row = append(row, fmt.Sprintf("%d", sumTotal)) - if sumTotal != 0 { - row = append(row, fmt.Sprintf("%d%s", percentage(sumTotal, sumFailed), "%")) - } else { - row = append(row, EmptyPercentage) - } - return row -} -func (printer *Printer) PrintSummaryTable() { - summaryTable := tablewriter.NewWriter(printer.writer) - summaryTable.SetAutoWrapText(false) - summaryTable.SetHeader(generateHeader()) - summaryTable.SetHeaderLine(true) - alignments := []int{tablewriter.ALIGN_LEFT, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER, tablewriter.ALIGN_CENTER} - summaryTable.SetColumnAlignment(alignments) - - for i := 0; i < len(printer.sortedControlNames); i++ { - controlSummary := printer.summary[printer.sortedControlNames[i]] - summaryTable.Append(generateRow(printer.sortedControlNames[i], controlSummary)) - } - summaryTable.SetFooter(generateFooter(len(printer.summary), printer.frameworkSummary.TotalFailed, printer.frameworkSummary.TotalWarnign, printer.frameworkSummary.TotalResources)) - summaryTable.Render() -} - -func (printer *Printer) getSortedControlsNames() []string { - controlNames := make([]string, 0, len(printer.summary)) - for k := range printer.summary { - controlNames = append(controlNames, k) - } - sort.Strings(controlNames) - return controlNames -} - -func getWriter(outputFile string) *os.File { - os.Remove(outputFile) - if outputFile != "" { - f, err := os.OpenFile(outputFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - fmt.Println("Error opening file") - return os.Stdout - } - return f - } - return os.Stdout - -} diff --git a/resultshandling/printer/silentprinter.go b/resultshandling/printer/silentprinter.go new file mode 100644 index 00000000..65737b5c --- /dev/null +++ b/resultshandling/printer/silentprinter.go @@ -0,0 +1,11 @@ +package printer + +import ( + "github.com/armosec/kubescape/cautils" +) + +type SilentPrinter struct { +} + +func (silentPrinter *SilentPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { +} diff --git a/resultshandling/reporter/mockreporter.go b/resultshandling/reporter/mockreporter.go new file mode 100644 index 00000000..21e37bb5 --- /dev/null +++ b/resultshandling/reporter/mockreporter.go @@ -0,0 +1,17 @@ +package reporter + +import "github.com/armosec/kubescape/cautils" + +type ReportMock struct { +} + +func NewReportMock() *ReportMock { + return &ReportMock{} +} +func (reportMock *ReportMock) ActionSendReport(opaSessionObj *cautils.OPASessionObj) {} + +func (reportMock *ReportMock) SetCustomerGUID(customerGUID string) { +} + +func (reportMock *ReportMock) SetClusterName(clusterName string) { +} diff --git a/resultshandling/reporter/reporteventreceiver.go b/resultshandling/reporter/reporteventreceiver.go index 99364328..dc27ad68 100644 --- a/resultshandling/reporter/reporteventreceiver.go +++ b/resultshandling/reporter/reporteventreceiver.go @@ -11,41 +11,52 @@ import ( "github.com/armosec/opa-utils/reporthandling" ) +type IReport interface { + ActionSendReport(opaSessionObj *cautils.OPASessionObj) + SetCustomerGUID(customerGUID string) + SetClusterName(clusterName string) +} + type ReportEventReceiver struct { - httpClient http.Client - host url.URL + httpClient http.Client + host url.URL + clusterName string + customerGUID string } func NewReportEventReceiver() *ReportEventReceiver { - hostURL := initEventReceiverURL() return &ReportEventReceiver{ httpClient: http.Client{}, - host: *hostURL, + // host: *hostURL, } } -func (report *ReportEventReceiver) ActionSendReportListenner(opaSessionObj *cautils.OPASessionObj) { - if cautils.CustomerGUID == "" { - return - } - //Add score - +func (report *ReportEventReceiver) ActionSendReport(opaSessionObj *cautils.OPASessionObj) { // Remove data before reporting keepFields := []string{"kind", "apiVersion", "metadata"} keepMetadataFields := []string{"name", "namespace", "labels"} opaSessionObj.PostureReport.RemoveData(keepFields, keepMetadataFields) - if err := report.Send(opaSessionObj.PostureReport); err != nil { + if err := report.send(opaSessionObj.PostureReport); err != nil { fmt.Println(err) } } -func (report *ReportEventReceiver) Send(postureReport *reporthandling.PostureReport) error { + +func (report *ReportEventReceiver) SetCustomerGUID(customerGUID string) { + report.customerGUID = customerGUID +} + +func (report *ReportEventReceiver) SetClusterName(clusterName string) { + report.clusterName = clusterName +} + +func (report *ReportEventReceiver) send(postureReport *reporthandling.PostureReport) error { reqBody, err := json.Marshal(*postureReport) if err != nil { return fmt.Errorf("in 'Send' failed to json.Marshal, reason: %v", err) } - host := hostToString(&report.host, postureReport.ReportID) + host := hostToString(report.initEventReceiverURL(), postureReport.ReportID) req, err := http.NewRequest("POST", host, bytes.NewReader(reqBody)) if err != nil { diff --git a/resultshandling/reporter/reporteventreceiverutils.go b/resultshandling/reporter/reporteventreceiverutils.go index 96fa75be..a32a3c22 100644 --- a/resultshandling/reporter/reporteventreceiverutils.go +++ b/resultshandling/reporter/reporteventreceiverutils.go @@ -7,7 +7,6 @@ import ( "net/url" "strings" - "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/cautils/getter" "github.com/gofrs/uuid" ) @@ -33,15 +32,15 @@ func httpRespToString(resp *http.Response) (string, error) { return strBuilder.String(), err } -func initEventReceiverURL() *url.URL { +func (report *ReportEventReceiver) initEventReceiverURL() *url.URL { urlObj := url.URL{} urlObj.Scheme = "https" urlObj.Host = getter.GetArmoAPIConnector().GetReportReceiverURL() urlObj.Path = "/k8s/postureReport" q := urlObj.Query() - q.Add("customerGUID", uuid.FromStringOrNil(cautils.CustomerGUID).String()) - q.Add("clusterName", cautils.ClusterName) + q.Add("customerGUID", uuid.FromStringOrNil(report.customerGUID).String()) + q.Add("clusterName", report.clusterName) urlObj.RawQuery = q.Encode() diff --git a/resultshandling/results.go b/resultshandling/results.go index 2db5e8f0..f3c6bbab 100644 --- a/resultshandling/results.go +++ b/resultshandling/results.go @@ -4,15 +4,16 @@ import ( "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/resultshandling/printer" "github.com/armosec/kubescape/resultshandling/reporter" + "github.com/armosec/opa-utils/reporthandling" ) type ResultsHandler struct { opaSessionObj *chan *cautils.OPASessionObj - reporterObj *reporter.ReportEventReceiver - printerObj *printer.Printer + reporterObj reporter.IReport + printerObj printer.IPrinter } -func NewResultsHandler(opaSessionObj *chan *cautils.OPASessionObj, reporterObj *reporter.ReportEventReceiver, printerObj *printer.Printer) *ResultsHandler { +func NewResultsHandler(opaSessionObj *chan *cautils.OPASessionObj, reporterObj reporter.IReport, printerObj printer.IPrinter) *ResultsHandler { return &ResultsHandler{ opaSessionObj: opaSessionObj, reporterObj: reporterObj, @@ -20,16 +21,28 @@ func NewResultsHandler(opaSessionObj *chan *cautils.OPASessionObj, reporterObj * } } -func (resultsHandler *ResultsHandler) HandleResults(scanInfo cautils.ScanInfo) float32 { +func (resultsHandler *ResultsHandler) HandleResults(scanInfo *cautils.ScanInfo) float32 { opaSessionObj := <-*resultsHandler.opaSessionObj - score := resultsHandler.printerObj.ActionPrint(opaSessionObj) + resultsHandler.printerObj.ActionPrint(opaSessionObj) - // Don't send report for control scan - if scanInfo.FrameworkScan { // TODO - use interface for ActionSendReportListenner - resultsHandler.reporterObj.ActionSendReportListenner(opaSessionObj) - } + resultsHandler.reporterObj.ActionSendReport(opaSessionObj) - return score + // TODO - get score from table + return CalculatePostureScore(opaSessionObj.PostureReport) +} + +// CalculatePostureScore calculate final score +func CalculatePostureScore(postureReport *reporthandling.PostureReport) float32 { + totalResources := 0 + totalFailed := 0 + for _, frameworkReport := range postureReport.FrameworkReports { + totalFailed += frameworkReport.GetNumberOfFailedResources() + totalResources += frameworkReport.GetNumberOfResources() + } + if totalResources == 0 { + return float32(0) + } + return (float32(totalResources) - float32(totalFailed)) / float32(totalResources) } From aec8198131700ed4779a96e78510d7aa7cbb0ed1 Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 25 Oct 2021 08:41:15 +0300 Subject: [PATCH 03/70] adding score to interface --- clihandler/initcli.go | 4 +--- resultshandling/printer/jsonprinter.go | 5 +++++ resultshandling/printer/junit.go | 5 +++++ resultshandling/printer/prettyprinter.go | 4 ++++ resultshandling/printer/printresults.go | 1 + resultshandling/reporter/reporteventreceiver.go | 3 --- resultshandling/results.go | 5 ++++- 7 files changed, 20 insertions(+), 7 deletions(-) diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 9dd35a85..0ac9289e 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -114,9 +114,7 @@ func CliSetup(scanInfo *cautils.ScanInfo) error { score := resultsHandling.HandleResults(scanInfo) // print report url - if scanInfo.FrameworkScan { - interfaces.clusterConfig.GenerateURL() - } + interfaces.clusterConfig.GenerateURL() adjustedFailThreshold := float32(scanInfo.FailThreshold) / 100 if score < adjustedFailThreshold { diff --git a/resultshandling/printer/jsonprinter.go b/resultshandling/printer/jsonprinter.go index 5a4a0f5b..26c4958d 100644 --- a/resultshandling/printer/jsonprinter.go +++ b/resultshandling/printer/jsonprinter.go @@ -19,6 +19,11 @@ func NewJsonPrinter() *JsonPrinter { func (jsonPrinter *JsonPrinter) SetWriter(outputFile string) { jsonPrinter.writer = getWriter(outputFile) } + +func (jsonPrinter *JsonPrinter) Score(score float32) { + fmt.Printf("\nFinal score: %d\n", int(score)) +} + func (jsonPrinter *JsonPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { postureReportStr, err := json.Marshal(opaSessionObj.PostureReport.FrameworkReports[0]) if err != nil { diff --git a/resultshandling/printer/junit.go b/resultshandling/printer/junit.go index cfa57003..1a9a7956 100644 --- a/resultshandling/printer/junit.go +++ b/resultshandling/printer/junit.go @@ -20,6 +20,11 @@ func NewJunitPrinter() *JunitPrinter { func (junitPrinter *JunitPrinter) SetWriter(outputFile string) { junitPrinter.writer = getWriter(outputFile) } + +func (junitPrinter *JunitPrinter) Score(score float32) { + fmt.Printf("\nFinal score: %d\n", int(score)) +} + func (junitPrinter *JunitPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { junitResult, err := convertPostureReportToJunitResult(opaSessionObj.PostureReport) if err != nil { diff --git a/resultshandling/printer/prettyprinter.go b/resultshandling/printer/prettyprinter.go index 8b814fa9..1d4b366b 100644 --- a/resultshandling/printer/prettyprinter.go +++ b/resultshandling/printer/prettyprinter.go @@ -37,6 +37,10 @@ func (printer *PrettyPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) func (printer *PrettyPrinter) SetWriter(outputFile string) { printer.writer = getWriter(outputFile) } + +func (printer *PrettyPrinter) Score(score float32) { +} + func (printer *PrettyPrinter) summarySetup(postureReport *reporthandling.PostureReport) { for _, fr := range postureReport.FrameworkReports { printer.frameworkSummary = ControlSummary{ diff --git a/resultshandling/printer/printresults.go b/resultshandling/printer/printresults.go index dcf4d4d7..8452c19f 100644 --- a/resultshandling/printer/printresults.go +++ b/resultshandling/printer/printresults.go @@ -17,6 +17,7 @@ const ( type IPrinter interface { ActionPrint(opaSessionObj *cautils.OPASessionObj) SetWriter(outputFile string) + Score(score float32) } func GetPrinter(printFormat string) IPrinter { diff --git a/resultshandling/reporter/reporteventreceiver.go b/resultshandling/reporter/reporteventreceiver.go index dc27ad68..070ba745 100644 --- a/resultshandling/reporter/reporteventreceiver.go +++ b/resultshandling/reporter/reporteventreceiver.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "github.com/armosec/kubescape/cautils" "github.com/armosec/opa-utils/reporthandling" @@ -19,7 +18,6 @@ type IReport interface { type ReportEventReceiver struct { httpClient http.Client - host url.URL clusterName string customerGUID string } @@ -27,7 +25,6 @@ type ReportEventReceiver struct { func NewReportEventReceiver() *ReportEventReceiver { return &ReportEventReceiver{ httpClient: http.Client{}, - // host: *hostURL, } } diff --git a/resultshandling/results.go b/resultshandling/results.go index f3c6bbab..25ddd3b2 100644 --- a/resultshandling/results.go +++ b/resultshandling/results.go @@ -30,7 +30,10 @@ func (resultsHandler *ResultsHandler) HandleResults(scanInfo *cautils.ScanInfo) resultsHandler.reporterObj.ActionSendReport(opaSessionObj) // TODO - get score from table - return CalculatePostureScore(opaSessionObj.PostureReport) + score := CalculatePostureScore(opaSessionObj.PostureReport) + resultsHandler.printerObj.Score(score) + + return score } // CalculatePostureScore calculate final score From beb4062bb122b2a6bce34a65cd55363fb2df77da Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Mon, 25 Oct 2021 09:29:43 +0300 Subject: [PATCH 04/70] update tag --- go.mod | 67 +--------------------------------------------------------- go.sum | 3 ++- 2 files changed, 3 insertions(+), 67 deletions(-) diff --git a/go.mod b/go.mod index 0e3cbc03..a598ae6f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.17 require ( github.com/armosec/armoapi-go v0.0.8 - github.com/armosec/k8s-interface v0.0.5 + github.com/armosec/k8s-interface v0.0.7 github.com/armosec/opa-utils v0.0.13 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 @@ -22,68 +22,3 @@ require ( k8s.io/apimachinery v0.22.2 k8s.io/client-go v0.22.2 ) - -require ( - cloud.google.com/go v0.81.0 // indirect - github.com/Azure/go-autorest v14.2.0+incompatible // indirect - github.com/Azure/go-autorest/autorest v0.11.18 // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect - github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect - github.com/Azure/go-autorest/logger v0.2.1 // indirect - github.com/Azure/go-autorest/tracing v0.6.0 // indirect - github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/armosec/utils-k8s-go v0.0.1 // indirect - github.com/coreos/go-oidc v2.2.1+incompatible // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/docker/docker v20.10.9+incompatible // indirect - github.com/docker/go-connections v0.4.0 // indirect - github.com/docker/go-units v0.4.0 // indirect - github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect - github.com/francoispqt/gojay v1.2.13 // indirect - github.com/ghodss/yaml v1.0.0 // indirect - github.com/go-gota/gota v0.12.0 // indirect - github.com/go-logr/logr v0.4.0 // indirect - github.com/gobwas/glob v0.2.3 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.2 // indirect - github.com/google/go-cmp v0.5.5 // indirect - github.com/google/gofuzz v1.1.0 // indirect - github.com/googleapis/gnostic v0.5.5 // indirect - github.com/imdario/mergo v0.3.12 // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect - github.com/json-iterator/go v1.1.11 // indirect - github.com/mattn/go-colorable v0.1.9 // indirect - github.com/mattn/go-runewidth v0.0.9 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.1 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.0.1 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/pquerna/cachecontrol v0.1.0 // indirect - github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect - github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect - github.com/yashtewari/glob-intersection v0.0.0-20180916065949-5c77d914dd0b // indirect - go.uber.org/atomic v1.7.0 // indirect - go.uber.org/multierr v1.6.0 // indirect - go.uber.org/zap v1.19.1 // indirect - golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 // indirect - golang.org/x/net v0.0.0-20210825183410-e898025ed96a // indirect - golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1 // indirect - golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf // indirect - golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect - golang.org/x/text v0.3.6 // indirect - golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect - gonum.org/v1/gonum v0.9.1 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.27.1 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/square/go-jose.v2 v2.6.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect - k8s.io/klog/v2 v2.9.0 // indirect - k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a // indirect - sigs.k8s.io/controller-runtime v0.10.2 // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect - sigs.k8s.io/yaml v1.2.0 // indirect -) diff --git a/go.sum b/go.sum index 842bf3e4..9422aad5 100644 --- a/go.sum +++ b/go.sum @@ -87,8 +87,9 @@ github.com/armosec/armoapi-go v0.0.2/go.mod h1:vIK17yoKbJRQyZXWWLe3AqfqCRITxW8qm github.com/armosec/armoapi-go v0.0.7/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= github.com/armosec/armoapi-go v0.0.8 h1:JPa9rZynuE2RucamDh6dsy/sjCScmWDsyt1zagJFCDo= github.com/armosec/armoapi-go v0.0.8/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= -github.com/armosec/k8s-interface v0.0.5 h1:DWQXZNMSsYQeLQ6xpB21ueFMR9oFnz28iWQTNn31TAk= github.com/armosec/k8s-interface v0.0.5/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= +github.com/armosec/k8s-interface v0.0.7 h1:VVhEsVjHndYyomRQuYx2B5iVlvrKDRZgZD16JHrv8+w= +github.com/armosec/k8s-interface v0.0.7/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= github.com/armosec/opa-utils v0.0.13 h1:QkmmYX0lzC7ZNGetyD8ysRKQHgJhjMfvRUW2cp+hz2o= github.com/armosec/opa-utils v0.0.13/go.mod h1:E0mFTVx+4BYAVvO2hxWnIniv/IZIogRCak8BkKd7KK4= github.com/armosec/utils-go v0.0.2/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= From 04b55e764a31bd07a9fa4671ff99d59b3d789b89 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Mon, 25 Oct 2021 10:44:43 +0300 Subject: [PATCH 05/70] fix k8s-interface pkg tag --- go.mod | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- go.sum | 4 ++-- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index a598ae6f..d4f7bbd6 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.17 require ( github.com/armosec/armoapi-go v0.0.8 - github.com/armosec/k8s-interface v0.0.7 + github.com/armosec/k8s-interface v0.0.8 github.com/armosec/opa-utils v0.0.13 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 @@ -22,3 +22,68 @@ require ( k8s.io/apimachinery v0.22.2 k8s.io/client-go v0.22.2 ) + +require ( + cloud.google.com/go v0.81.0 // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.18 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.13 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect + github.com/Azure/go-autorest/logger v0.2.1 // indirect + github.com/Azure/go-autorest/tracing v0.6.0 // indirect + github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/armosec/utils-k8s-go v0.0.1 // indirect + github.com/coreos/go-oidc v2.2.1+incompatible // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/docker v20.10.9+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.4.0 // indirect + github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect + github.com/francoispqt/gojay v1.2.13 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-gota/gota v0.12.0 // indirect + github.com/go-logr/logr v0.4.0 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-cmp v0.5.5 // indirect + github.com/google/gofuzz v1.1.0 // indirect + github.com/googleapis/gnostic v0.5.5 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/json-iterator/go v1.1.11 // indirect + github.com/mattn/go-colorable v0.1.9 // indirect + github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pquerna/cachecontrol v0.1.0 // indirect + github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/yashtewari/glob-intersection v0.0.0-20180916065949-5c77d914dd0b // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect + go.uber.org/zap v1.19.1 // indirect + golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 // indirect + golang.org/x/net v0.0.0-20210825183410-e898025ed96a // indirect + golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1 // indirect + golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf // indirect + golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect + golang.org/x/text v0.3.6 // indirect + golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect + gonum.org/v1/gonum v0.9.1 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.27.1 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/square/go-jose.v2 v2.6.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + k8s.io/klog/v2 v2.9.0 // indirect + k8s.io/utils v0.0.0-20210819203725-bdf08cb9a70a // indirect + sigs.k8s.io/controller-runtime v0.10.2 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect + sigs.k8s.io/yaml v1.2.0 // indirect +) diff --git a/go.sum b/go.sum index 9422aad5..e21fe68c 100644 --- a/go.sum +++ b/go.sum @@ -88,8 +88,8 @@ github.com/armosec/armoapi-go v0.0.7/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9y github.com/armosec/armoapi-go v0.0.8 h1:JPa9rZynuE2RucamDh6dsy/sjCScmWDsyt1zagJFCDo= github.com/armosec/armoapi-go v0.0.8/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= github.com/armosec/k8s-interface v0.0.5/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= -github.com/armosec/k8s-interface v0.0.7 h1:VVhEsVjHndYyomRQuYx2B5iVlvrKDRZgZD16JHrv8+w= -github.com/armosec/k8s-interface v0.0.7/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= +github.com/armosec/k8s-interface v0.0.8 h1:Eo3Qen4yFXxzVem49FNeij2ckyzHSAJ0w6PZMaSEIm8= +github.com/armosec/k8s-interface v0.0.8/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= github.com/armosec/opa-utils v0.0.13 h1:QkmmYX0lzC7ZNGetyD8ysRKQHgJhjMfvRUW2cp+hz2o= github.com/armosec/opa-utils v0.0.13/go.mod h1:E0mFTVx+4BYAVvO2hxWnIniv/IZIogRCak8BkKd7KK4= github.com/armosec/utils-go v0.0.2/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= From 740b5aa7723246d5bd7af6d695711264eeee5348 Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 25 Oct 2021 11:55:08 +0300 Subject: [PATCH 06/70] add full url --- cautils/customerloader.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/cautils/customerloader.go b/cautils/customerloader.go index 499e7630..4c839583 100644 --- a/cautils/customerloader.go +++ b/cautils/customerloader.go @@ -158,6 +158,7 @@ func (c *ClusterConfig) GetDefaultNS() string { return c.defau func (c *ClusterConfig) GetBackendAPI() getter.IBackend { return c.backendAPI } func (c *ClusterConfig) GenerateURL() { + message := "\nCheckout for more cool features: " u := url.URL{} u.Scheme = "https" @@ -165,9 +166,8 @@ func (c *ClusterConfig) GenerateURL() { if c.configObj == nil { return } - message := fmt.Sprintf("\nCheckout for more cool features: https://%s\n", getter.GetArmoAPIConnector().GetFrontendURL()) if c.configObj.CustomerAdminEMail != "" { - InfoTextDisplay(os.Stdout, message+"\n") + InfoTextDisplay(os.Stdout, message+u.String()+"\n") return } u.Path = "account/sign-up" @@ -176,8 +176,7 @@ func (c *ClusterConfig) GenerateURL() { q.Add("customerGUID", c.configObj.CustomerGUID) u.RawQuery = q.Encode() - InfoTextDisplay(os.Stdout, message+"\n") - + InfoTextDisplay(os.Stdout, message+u.String()+"\n") } func (c *ClusterConfig) GetCustomerGUID() string { From d72700acf6754a43c949b731e6d782b2451d383b Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 25 Oct 2021 12:05:51 +0300 Subject: [PATCH 07/70] update submit --- cautils/customerloader.go | 8 +++++--- clihandler/initcli.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cautils/customerloader.go b/cautils/customerloader.go index 4c839583..44b79930 100644 --- a/cautils/customerloader.go +++ b/cautils/customerloader.go @@ -109,8 +109,10 @@ func ClusterConfigSetup(scanInfo *ScanInfo, k8s *k8sinterface.KubernetesApi, beA return NewEmptyConfig() // local - Delete local config & Do not send report } if scanInfo.Local { + scanInfo.Submit = false return NewEmptyConfig() // local - Do not send report } + scanInfo.Submit = true return clusterConfig // submit/default - Submit report } @@ -158,7 +160,7 @@ func (c *ClusterConfig) GetDefaultNS() string { return c.defau func (c *ClusterConfig) GetBackendAPI() getter.IBackend { return c.backendAPI } func (c *ClusterConfig) GenerateURL() { - message := "\nCheckout for more cool features: " + message := "Checkout for more cool features: " u := url.URL{} u.Scheme = "https" @@ -167,7 +169,7 @@ func (c *ClusterConfig) GenerateURL() { return } if c.configObj.CustomerAdminEMail != "" { - InfoTextDisplay(os.Stdout, message+u.String()+"\n") + InfoTextDisplay(os.Stdout, "\n\n"+message+u.String()+"\n\n") return } u.Path = "account/sign-up" @@ -176,7 +178,7 @@ func (c *ClusterConfig) GenerateURL() { q.Add("customerGUID", c.configObj.CustomerGUID) u.RawQuery = q.Encode() - InfoTextDisplay(os.Stdout, message+u.String()+"\n") + InfoTextDisplay(os.Stdout, "\n\n"+message+u.String()+"\n\n") } func (c *ClusterConfig) GetCustomerGUID() string { diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 0ac9289e..1fdaae44 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -34,7 +34,7 @@ type componentInterfaces struct { } func getReporter(scanInfo *cautils.ScanInfo) reporter.IReport { - if scanInfo.Local { + if !scanInfo.Submit { return reporter.NewReportMock() } if !scanInfo.FrameworkScan { From 41dfdfd1e8c49b48738be4d32a241f51c6fdce0e Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 25 Oct 2021 15:14:31 +0300 Subject: [PATCH 08/70] support more than score --- resultshandling/printer/jsonprinter.go | 2 +- resultshandling/printer/junit.go | 2 +- resultshandling/results.go | 18 ++++++++++-------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/resultshandling/printer/jsonprinter.go b/resultshandling/printer/jsonprinter.go index 26c4958d..ff80feb8 100644 --- a/resultshandling/printer/jsonprinter.go +++ b/resultshandling/printer/jsonprinter.go @@ -21,7 +21,7 @@ func (jsonPrinter *JsonPrinter) SetWriter(outputFile string) { } func (jsonPrinter *JsonPrinter) Score(score float32) { - fmt.Printf("\nFinal score: %d\n", int(score)) + fmt.Printf("\nFinal score: %d", int(score*100)) } func (jsonPrinter *JsonPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { diff --git a/resultshandling/printer/junit.go b/resultshandling/printer/junit.go index f778c972..ac03753d 100644 --- a/resultshandling/printer/junit.go +++ b/resultshandling/printer/junit.go @@ -22,7 +22,7 @@ func (junitPrinter *JunitPrinter) SetWriter(outputFile string) { } func (junitPrinter *JunitPrinter) Score(score float32) { - fmt.Printf("\nFinal score: %d\n", int(score)) + fmt.Printf("\nFinal score: %d", int(score*100)) } func (junitPrinter *JunitPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { diff --git a/resultshandling/results.go b/resultshandling/results.go index 25ddd3b2..80802357 100644 --- a/resultshandling/results.go +++ b/resultshandling/results.go @@ -38,14 +38,16 @@ func (resultsHandler *ResultsHandler) HandleResults(scanInfo *cautils.ScanInfo) // CalculatePostureScore calculate final score func CalculatePostureScore(postureReport *reporthandling.PostureReport) float32 { - totalResources := 0 - totalFailed := 0 + lowestScore := float32(0) for _, frameworkReport := range postureReport.FrameworkReports { - totalFailed += frameworkReport.GetNumberOfFailedResources() - totalResources += frameworkReport.GetNumberOfResources() + totalFailed := frameworkReport.GetNumberOfFailedResources() + totalResources := frameworkReport.GetNumberOfResources() + + frameworkScore := (float32(totalResources) - float32(totalFailed)) / float32(totalResources) + if lowestScore > frameworkScore || frameworkScore == 0 { + frameworkScore = lowestScore + } } - if totalResources == 0 { - return float32(0) - } - return (float32(totalResources) - float32(totalFailed)) / float32(totalResources) + + return lowestScore } From 934c9ccc8bab3bb7089f1927cf5ad1c38d7dfe2b Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 25 Oct 2021 15:51:23 +0300 Subject: [PATCH 09/70] fixed lowest --- resultshandling/results.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/resultshandling/results.go b/resultshandling/results.go index 80802357..1ba9aacf 100644 --- a/resultshandling/results.go +++ b/resultshandling/results.go @@ -38,14 +38,17 @@ func (resultsHandler *ResultsHandler) HandleResults(scanInfo *cautils.ScanInfo) // CalculatePostureScore calculate final score func CalculatePostureScore(postureReport *reporthandling.PostureReport) float32 { - lowestScore := float32(0) + lowestScore := float32(100) for _, frameworkReport := range postureReport.FrameworkReports { totalFailed := frameworkReport.GetNumberOfFailedResources() totalResources := frameworkReport.GetNumberOfResources() - frameworkScore := (float32(totalResources) - float32(totalFailed)) / float32(totalResources) - if lowestScore > frameworkScore || frameworkScore == 0 { - frameworkScore = lowestScore + frameworkScore := float32(0) + if float32(totalResources) > 0 { + frameworkScore = (float32(totalResources) - float32(totalFailed)) / float32(totalResources) + } + if lowestScore > frameworkScore { + lowestScore = frameworkScore } } From 3b8825e5d20b87af1f416ed9dc7a5dc3c5fa8789 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Mon, 25 Oct 2021 17:41:04 +0300 Subject: [PATCH 10/70] scan multiple frameworks and controls --- cautils/scaninfo.go | 4 +- clihandler/cmd/control.go | 45 ++++++++++++++++++---- clihandler/cmd/framework.go | 49 +++++++++++++++++------- clihandler/cmd/version.go | 2 +- clihandler/initcli.go | 6 +-- main.go | 6 ++- resultshandling/printer/prettyprinter.go | 49 ++++++++++++------------ 7 files changed, 108 insertions(+), 53 deletions(-) diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index 305b400c..6c6c31fa 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -9,7 +9,7 @@ import ( type ScanInfo struct { Getters - PolicyIdentifier reporthandling.PolicyIdentifier + PolicyIdentifier []reporthandling.PolicyIdentifier UseExceptions string // Load exceptions configuration UseFrom string // Load framework from local file (instead of download). Use when running offline UseDefault bool // Load framework from cached file (instead of download). Use when running offline @@ -52,7 +52,7 @@ func (scanInfo *ScanInfo) setUseFrom() { return } if scanInfo.UseDefault { - scanInfo.UseFrom = getter.GetDefaultPath(scanInfo.PolicyIdentifier.Name + ".json") + scanInfo.UseFrom = getter.GetDefaultPath(scanInfo.PolicyIdentifier[0].Name + ".json") } } func (scanInfo *ScanInfo) setGetter() { diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index e89ba905..967906a1 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -13,22 +13,40 @@ import ( // controlCmd represents the control command var controlCmd = &cobra.Command{ - Use: "control /", + Use: "control /", Short: fmt.Sprintf("The control you wish to use for scan. It must be present in at least one of the folloiwng frameworks: %s", clihandler.ValidFrameworks), Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 && !(cmd.Flags().Lookup("use-from").Changed) { - return fmt.Errorf("requires at least one argument") + if len(args) > 0 { + controls := strings.Split(args[0], ",") + if len(controls) > 1 { + if controls[1] == "" { + return fmt.Errorf("usage: ,") + } + } } return nil }, RunE: func(cmd *cobra.Command, args []string) error { flagValidationControl() - scanInfo.PolicyIdentifier = reporthandling.PolicyIdentifier{} - if !(cmd.Flags().Lookup("use-from").Changed) { - scanInfo.PolicyIdentifier.Name = strings.ToLower(args[0]) + scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} + + if len(args) < 1 && !(cmd.Flags().Lookup("use-from").Changed) { + scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) + } else { + controls := strings.Split(args[0], ",") + scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindControl + newPolicy.Name = controls[0] + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + + if !(cmd.Flags().Lookup("use-from").Changed) { + if len(controls) > 1 { + scanInfo.PolicyIdentifier = SetScanForGivenControls(controls[1:]) + } + } } scanInfo.FrameworkScan = false - scanInfo.PolicyIdentifier.Kind = reporthandling.KindControl scanInfo.Init() cautils.SetSilentMode(scanInfo.Silent) err := clihandler.CliSetup(&scanInfo) @@ -51,3 +69,16 @@ func flagValidationControl() { os.Exit(1) } } + +func SetScanForGivenControls(controls []string) []reporthandling.PolicyIdentifier { + for _, control := range controls { + if control[0] == ' ' { + control = control[1:] + } + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindControl + newPolicy.Name = control + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + } + return scanInfo.PolicyIdentifier +} diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 9461dce5..2bbd1d0e 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -9,32 +9,46 @@ import ( "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/clihandler" "github.com/armosec/opa-utils/reporthandling" - "github.com/spf13/cobra" ) var frameworkCmd = &cobra.Command{ - - Use: fmt.Sprintf("framework [``/`-`] [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), + Use: fmt.Sprintf("framework [``/`-`] [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), Short: fmt.Sprintf("The framework you wish to use. Supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", ")), Long: "Execute a scan on a running Kubernetes cluster or `yaml`/`json` files (use glob) or `-` for stdin", ValidArgs: clihandler.SupportedFrameworks, Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 1 && !(cmd.Flags().Lookup("use-from").Changed) { - return fmt.Errorf("requires at least one argument") - } else if len(args) > 0 { - if !isValidFramework(strings.ToLower(args[0])) { - return fmt.Errorf(fmt.Sprintf("supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", "))) + if len(args) > 0 { + // "nsa, mitre" -> ["nsa", "mitre"] and nsa,mitre -> ["nsa", "mitre"] + frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") + for _, framework := range frameworks { + if !isValidFramework(strings.ToLower(framework)) { + return fmt.Errorf(fmt.Sprintf("supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", "))) + } } } return nil }, RunE: func(cmd *cobra.Command, args []string) error { - scanInfo.PolicyIdentifier = reporthandling.PolicyIdentifier{} - scanInfo.PolicyIdentifier.Kind = reporthandling.KindFramework flagValidationFramework() - if !(cmd.Flags().Lookup("use-from").Changed) { - scanInfo.PolicyIdentifier.Name = strings.ToLower(args[0]) + scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} + // If no framework provided, use all + if len(args) < 1 && !(cmd.Flags().Lookup("use-from").Changed) { + scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) + } else { + // Read frameworks from input args + frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") + scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindFramework + newPolicy.Name = frameworks[0] + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + + if !(cmd.Flags().Lookup("use-from").Changed) && !(cmd.Flags().Lookup("use-default").Changed) { + if len(frameworks) > 1 { + scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(frameworks[1:]) + } + } } if len(args) > 0 { if len(args[1:]) == 0 || args[1] != "-" { @@ -77,8 +91,17 @@ func init() { } -func flagValidationFramework() { +func SetScanForGivenFrameworks(frameworks []string) []reporthandling.PolicyIdentifier { + for _, framework := range frameworks { + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindFramework + newPolicy.Name = framework + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + } + return scanInfo.PolicyIdentifier +} +func flagValidationFramework() { if scanInfo.Submit && scanInfo.Local { fmt.Println("You can use `keep-local` or `submit`, but not both") os.Exit(1) diff --git a/clihandler/cmd/version.go b/clihandler/cmd/version.go index 45ba5bc2..f4623813 100644 --- a/clihandler/cmd/version.go +++ b/clihandler/cmd/version.go @@ -29,7 +29,7 @@ func GetLatestVersion() (string, error) { } defer resp.Body.Close() if resp.StatusCode < 200 || 301 < resp.StatusCode { - return "unknown", fmt.Errorf("failed to download file, status code: %s", resp.Status) + return "unknown", nil } body, err := io.ReadAll(resp.Body) diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 1fdaae44..a502cc74 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -135,10 +135,8 @@ func (clihandler *CLIHandler) Scan() error { cautils.ScanStartDisplay() policyNotification := &reporthandling.PolicyNotification{ NotificationType: reporthandling.TypeExecPostureScan, - Rules: []reporthandling.PolicyIdentifier{ - clihandler.scanInfo.PolicyIdentifier, - }, - Designators: armotypes.PortalDesignator{}, + Rules: clihandler.scanInfo.PolicyIdentifier, + Designators: armotypes.PortalDesignator{}, } switch policyNotification.NotificationType { case reporthandling.TypeExecPostureScan: diff --git a/main.go b/main.go index 956b6622..dd5e7bff 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,11 @@ func CheckLatestVersion() { if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) } else if latest != cmd.BuildNumber { - fmt.Println("Warning: You are not updated to the latest release: " + latest) + if latest == "unknown" { + fmt.Println("Warning: You are not updated to the latest release!") + } else { + fmt.Println("Warning: You are not updated to the latest release: " + latest) + } } } diff --git a/resultshandling/printer/prettyprinter.go b/resultshandling/printer/prettyprinter.go index 1d4b366b..2d2c1361 100644 --- a/resultshandling/printer/prettyprinter.go +++ b/resultshandling/printer/prettyprinter.go @@ -26,10 +26,11 @@ func NewPrettyPrinter() *PrettyPrinter { func (printer *PrettyPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { // score := calculatePostureScore(opaSessionObj.PostureReport) - - printer.summarySetup(opaSessionObj.PostureReport) - printer.printResults() - printer.printSummaryTable() + for _, report := range opaSessionObj.PostureReport.FrameworkReports { + printer.summarySetup(report) + printer.printResults() + printer.printSummaryTable() + } // return score } @@ -41,29 +42,27 @@ func (printer *PrettyPrinter) SetWriter(outputFile string) { func (printer *PrettyPrinter) Score(score float32) { } -func (printer *PrettyPrinter) summarySetup(postureReport *reporthandling.PostureReport) { - for _, fr := range postureReport.FrameworkReports { - printer.frameworkSummary = ControlSummary{ - TotalResources: fr.GetNumberOfResources(), - TotalFailed: fr.GetNumberOfFailedResources(), - TotalWarnign: fr.GetNumberOfWarningResources(), +func (printer *PrettyPrinter) summarySetup(fr reporthandling.FrameworkReport) { + printer.frameworkSummary = ControlSummary{ + TotalResources: fr.GetNumberOfResources(), + TotalFailed: fr.GetNumberOfFailedResources(), + TotalWarnign: fr.GetNumberOfWarningResources(), + } + for _, cr := range fr.ControlReports { + if len(cr.RuleReports) == 0 { + continue } - for _, cr := range fr.ControlReports { - if len(cr.RuleReports) == 0 { - continue - } - workloadsSummary := listResultSummary(cr.RuleReports) + workloadsSummary := listResultSummary(cr.RuleReports) - printer.summary[cr.Name] = ControlSummary{ - TotalResources: cr.GetNumberOfResources(), - TotalFailed: cr.GetNumberOfFailedResources(), - TotalWarnign: cr.GetNumberOfWarningResources(), - FailedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryFailed), - ExcludedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryExclude), - Description: cr.Description, - Remediation: cr.Remediation, - ListInputKinds: cr.ListControlsInputKinds(), - } + printer.summary[cr.Name] = ControlSummary{ + TotalResources: cr.GetNumberOfResources(), + TotalFailed: cr.GetNumberOfFailedResources(), + TotalWarnign: cr.GetNumberOfWarningResources(), + FailedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryFailed), + ExcludedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryExclude), + Description: cr.Description, + Remediation: cr.Remediation, + ListInputKinds: cr.ListControlsInputKinds(), } } printer.sortedControlNames = printer.getSortedControlsNames() From bd24f35738c92d63b6640bcf5ccd99205437b992 Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 26 Oct 2021 12:26:44 +0300 Subject: [PATCH 11/70] update tag --- cautils/getter/downloadreleasedpolicy.go | 2 +- cautils/getter/loadpolicy.go | 10 ++++++++-- go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/cautils/getter/downloadreleasedpolicy.go b/cautils/getter/downloadreleasedpolicy.go index 944c1bd1..23ca29e5 100644 --- a/cautils/getter/downloadreleasedpolicy.go +++ b/cautils/getter/downloadreleasedpolicy.go @@ -18,7 +18,7 @@ type DownloadReleasedPolicy struct { func NewDownloadReleasedPolicy() *DownloadReleasedPolicy { return &DownloadReleasedPolicy{ - gs: gitregostore.InitDefaultGitRegoStore(), + gs: gitregostore.InitDefaultGitRegoStore(-1), } } diff --git a/cautils/getter/loadpolicy.go b/cautils/getter/loadpolicy.go index c976a478..223ee7ec 100644 --- a/cautils/getter/loadpolicy.go +++ b/cautils/getter/loadpolicy.go @@ -35,7 +35,10 @@ func (lp *LoadPolicy) GetControl(controlName string) (*reporthandling.Control, e return nil, err } - err = json.Unmarshal(f, control) + if err = json.Unmarshal(f, control); err != nil { + return control, err + } + if controlName != "" && !strings.EqualFold(controlName, control.Name) && !strings.EqualFold(controlName, control.ControlID) { return nil, fmt.Errorf("control from file not matching") } @@ -50,7 +53,10 @@ func (lp *LoadPolicy) GetFramework(frameworkName string) (*reporthandling.Framew return nil, err } - err = json.Unmarshal(f, framework) + if err = json.Unmarshal(f, framework); err != nil { + return framework, err + } + if frameworkName != "" && !strings.EqualFold(frameworkName, framework.Name) { return nil, fmt.Errorf("framework from file not matching") } diff --git a/go.mod b/go.mod index d4f7bbd6..508431cb 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.17 require ( github.com/armosec/armoapi-go v0.0.8 github.com/armosec/k8s-interface v0.0.8 - github.com/armosec/opa-utils v0.0.13 + github.com/armosec/opa-utils v0.0.18 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 github.com/enescakir/emoji v1.0.0 diff --git a/go.sum b/go.sum index e21fe68c..2ef3ea74 100644 --- a/go.sum +++ b/go.sum @@ -90,8 +90,8 @@ github.com/armosec/armoapi-go v0.0.8/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9y github.com/armosec/k8s-interface v0.0.5/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= github.com/armosec/k8s-interface v0.0.8 h1:Eo3Qen4yFXxzVem49FNeij2ckyzHSAJ0w6PZMaSEIm8= github.com/armosec/k8s-interface v0.0.8/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= -github.com/armosec/opa-utils v0.0.13 h1:QkmmYX0lzC7ZNGetyD8ysRKQHgJhjMfvRUW2cp+hz2o= -github.com/armosec/opa-utils v0.0.13/go.mod h1:E0mFTVx+4BYAVvO2hxWnIniv/IZIogRCak8BkKd7KK4= +github.com/armosec/opa-utils v0.0.18 h1:1hL5v2KCD8yStuwzul+gq1zg9+RCV9N3kHoRepKnrg0= +github.com/armosec/opa-utils v0.0.18/go.mod h1:E0mFTVx+4BYAVvO2hxWnIniv/IZIogRCak8BkKd7KK4= github.com/armosec/utils-go v0.0.2/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= github.com/armosec/utils-go v0.0.3 h1:uyQI676yRciQM0sSN9uPoqHkbspTxHO0kmzXhBeE/xU= github.com/armosec/utils-go v0.0.3/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= From a0ca68cc41f1c7cdb8bdd63e79e965bfca99b729 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Tue, 26 Oct 2021 13:55:12 +0300 Subject: [PATCH 12/70] update json and junit for multiple frameworks --- clihandler/cmd/framework.go | 2 -- clihandler/cmd/scan.go | 15 ++++++++++----- resultshandling/printer/jsonprinter.go | 10 +++++++++- resultshandling/printer/prettyprinter.go | 20 ++++++++++++++++++-- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 2bbd1d0e..4110f028 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -43,7 +43,6 @@ var frameworkCmd = &cobra.Command{ newPolicy.Kind = reporthandling.KindFramework newPolicy.Name = frameworks[0] scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) - if !(cmd.Flags().Lookup("use-from").Changed) && !(cmd.Flags().Lookup("use-default").Changed) { if len(frameworks) > 1 { scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(frameworks[1:]) @@ -88,7 +87,6 @@ func init() { frameworkCmd.Flags().BoolVarP(&scanInfo.Submit, "submit", "", false, "Send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not submitted") frameworkCmd.Flags().BoolVarP(&scanInfo.Local, "keep-local", "", false, "If you do not want your Kubescape results reported to Armo backend. Use this flag if you ran with the '--submit' flag in the past and you do not want to submit your current scan results") frameworkCmd.Flags().StringVarP(&scanInfo.Account, "account", "", "", "Armo portal account ID. Default will load account ID from configMap or config file") - } func SetScanForGivenFrameworks(frameworks []string) []reporthandling.PolicyIdentifier { diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index e86e5a02..9b0e46bb 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/clihandler" "github.com/spf13/cobra" ) @@ -16,15 +17,19 @@ var scanCmd = &cobra.Command{ Short: "Scan the current running cluster or yaml files", Long: `The action you want to perform`, Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - return fmt.Errorf("requires one argument: framework/control") - } - if !strings.EqualFold(args[0], "framework") && !strings.EqualFold(args[0], "control") { - return fmt.Errorf("invalid parameter '%s'. Supported parameters: framework, control", args[0]) + if len(args) > 0 { + if !strings.EqualFold(args[0], "framework") && !strings.EqualFold(args[0], "control") { + return fmt.Errorf("invalid parameter '%s'. Supported parameters: framework, control", args[0]) + } } return nil }, Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + frameworkArgs := []string{clihandler.ValidFrameworks} + frameworkArgs = append(frameworkArgs, args...) + frameworkCmd.RunE(cmd, frameworkArgs) + } }, } diff --git a/resultshandling/printer/jsonprinter.go b/resultshandling/printer/jsonprinter.go index ff80feb8..01acad7e 100644 --- a/resultshandling/printer/jsonprinter.go +++ b/resultshandling/printer/jsonprinter.go @@ -25,7 +25,15 @@ func (jsonPrinter *JsonPrinter) Score(score float32) { } func (jsonPrinter *JsonPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { - postureReportStr, err := json.Marshal(opaSessionObj.PostureReport.FrameworkReports[0]) + var postureReportStr []byte + var err error + + if len(opaSessionObj.PostureReport.FrameworkReports) == 1 { + postureReportStr, err = json.Marshal(opaSessionObj.PostureReport.FrameworkReports[0]) + } else { + postureReportStr, err = json.Marshal(opaSessionObj.PostureReport.FrameworkReports) + } + if err != nil { fmt.Println("Failed to convert posture report object!") os.Exit(1) diff --git a/resultshandling/printer/prettyprinter.go b/resultshandling/printer/prettyprinter.go index 2d2c1361..0752fa00 100644 --- a/resultshandling/printer/prettyprinter.go +++ b/resultshandling/printer/prettyprinter.go @@ -24,12 +24,24 @@ func NewPrettyPrinter() *PrettyPrinter { } } +// Initializes empty printer for new table +func (printer *PrettyPrinter) init() *PrettyPrinter { + printer.frameworkSummary = ControlSummary{} + printer.summary = Summary{} + printer.sortedControlNames = []string{} + return printer +} + func (printer *PrettyPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { // score := calculatePostureScore(opaSessionObj.PostureReport) for _, report := range opaSessionObj.PostureReport.FrameworkReports { + // Print summary table together for control scan + if report.Name != "" { + printer = printer.init() + } printer.summarySetup(report) printer.printResults() - printer.printSummaryTable() + printer.printSummaryTable(report.Name) } // return score @@ -178,7 +190,11 @@ func generateFooter(numControlers, sumFailed, sumWarning, sumTotal int) []string } return row } -func (printer *PrettyPrinter) printSummaryTable() { +func (printer *PrettyPrinter) printSummaryTable(framework string) { + // For control scan framework will be nil + if framework != "" { + cautils.InfoTextDisplay(printer.writer, fmt.Sprintf("%s FRAMEWORK\n", framework)) + } summaryTable := tablewriter.NewWriter(printer.writer) summaryTable.SetAutoWrapText(false) summaryTable.SetHeader(generateHeader()) From a5ef6aa12673fecbcd84d8d9eae0dbcafd674c62 Mon Sep 17 00:00:00 2001 From: Alan Clucas Date: Tue, 26 Oct 2021 15:12:17 +0100 Subject: [PATCH 13/70] Minor spelling fixes --- README.md | 2 +- resultshandling/printer/prettyprinter.go | 12 ++++++------ resultshandling/printer/summary.go | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 6bcc3cb6..8d893934 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ Kubescape is an open source project, we welcome your feedback and ideas for impr ## Build using python (3.7^) script -Kubescpae can be built using: +Kubescape can be built using: ``` sh python build.py diff --git a/resultshandling/printer/prettyprinter.go b/resultshandling/printer/prettyprinter.go index 1d4b366b..e0b54715 100644 --- a/resultshandling/printer/prettyprinter.go +++ b/resultshandling/printer/prettyprinter.go @@ -46,7 +46,7 @@ func (printer *PrettyPrinter) summarySetup(postureReport *reporthandling.Posture printer.frameworkSummary = ControlSummary{ TotalResources: fr.GetNumberOfResources(), TotalFailed: fr.GetNumberOfFailedResources(), - TotalWarnign: fr.GetNumberOfWarningResources(), + TotalWarning: fr.GetNumberOfWarningResources(), } for _, cr := range fr.ControlReports { if len(cr.RuleReports) == 0 { @@ -57,7 +57,7 @@ func (printer *PrettyPrinter) summarySetup(postureReport *reporthandling.Posture printer.summary[cr.Name] = ControlSummary{ TotalResources: cr.GetNumberOfResources(), TotalFailed: cr.GetNumberOfFailedResources(), - TotalWarnign: cr.GetNumberOfWarningResources(), + TotalWarning: cr.GetNumberOfWarningResources(), FailedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryFailed), ExcludedWorkloads: groupByNamespace(workloadsSummary, workloadSummaryExclude), Description: cr.Description, @@ -82,8 +82,8 @@ func (printer *PrettyPrinter) printResults() { func (printer *PrettyPrinter) printSummary(controlName string, controlSummary *ControlSummary) { cautils.SimpleDisplay(printer.writer, "Summary - ") - cautils.SuccessDisplay(printer.writer, "Passed:%v ", controlSummary.TotalResources-controlSummary.TotalFailed-controlSummary.TotalWarnign) - cautils.WarningDisplay(printer.writer, "Excluded:%v ", controlSummary.TotalWarnign) + cautils.SuccessDisplay(printer.writer, "Passed:%v ", controlSummary.TotalResources-controlSummary.TotalFailed-controlSummary.TotalWarning) + cautils.WarningDisplay(printer.writer, "Excluded:%v ", controlSummary.TotalWarning) cautils.FailureDisplay(printer.writer, "Failed:%v ", controlSummary.TotalFailed) cautils.InfoDisplay(printer.writer, "Total:%v\n", controlSummary.TotalResources) if controlSummary.TotalFailed > 0 { @@ -99,7 +99,7 @@ func (printer *PrettyPrinter) printTitle(controlName string, controlSummary *Con cautils.InfoDisplay(printer.writer, "resources not found %v\n", emoji.ConfusedFace) } else if controlSummary.TotalFailed != 0 { cautils.FailureDisplay(printer.writer, "failed %v\n", emoji.SadButRelievedFace) - } else if controlSummary.TotalWarnign != 0 { + } else if controlSummary.TotalWarning != 0 { cautils.WarningDisplay(printer.writer, "excluded %v\n", emoji.NeutralFace) } else { cautils.SuccessDisplay(printer.writer, "passed %v\n", emoji.ThumbsUp) @@ -191,7 +191,7 @@ func (printer *PrettyPrinter) printSummaryTable() { controlSummary := printer.summary[printer.sortedControlNames[i]] summaryTable.Append(generateRow(printer.sortedControlNames[i], controlSummary)) } - summaryTable.SetFooter(generateFooter(len(printer.summary), printer.frameworkSummary.TotalFailed, printer.frameworkSummary.TotalWarnign, printer.frameworkSummary.TotalResources)) + summaryTable.SetFooter(generateFooter(len(printer.summary), printer.frameworkSummary.TotalFailed, printer.frameworkSummary.TotalWarning, printer.frameworkSummary.TotalResources)) summaryTable.Render() } diff --git a/resultshandling/printer/summary.go b/resultshandling/printer/summary.go index 9df245f6..056f6d23 100644 --- a/resultshandling/printer/summary.go +++ b/resultshandling/printer/summary.go @@ -15,7 +15,7 @@ func NewSummary() Summary { type ControlSummary struct { TotalResources int TotalFailed int - TotalWarnign int + TotalWarning int Description string Remediation string ListInputKinds []string @@ -34,7 +34,7 @@ type WorkloadSummary struct { func (controlSummary *ControlSummary) ToSlice() []string { s := []string{} s = append(s, fmt.Sprintf("%d", controlSummary.TotalFailed)) - s = append(s, fmt.Sprintf("%d", controlSummary.TotalWarnign)) + s = append(s, fmt.Sprintf("%d", controlSummary.TotalWarning)) s = append(s, fmt.Sprintf("%d", controlSummary.TotalResources)) return s } From 0c9da9ddc8d45bd74e28cdd6c62a70f9b1515ed2 Mon Sep 17 00:00:00 2001 From: Alan Clucas Date: Tue, 26 Oct 2021 14:56:55 +0100 Subject: [PATCH 14/70] Add a prometheus metrics style output Output per control results and also per object counts This can lead to running this as a service that prometheus can collect from --- resultshandling/printer/printresults.go | 7 +- resultshandling/printer/prometheusprinter.go | 100 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 resultshandling/printer/prometheusprinter.go diff --git a/resultshandling/printer/printresults.go b/resultshandling/printer/printresults.go index 8452c19f..eab0c5d9 100644 --- a/resultshandling/printer/printresults.go +++ b/resultshandling/printer/printresults.go @@ -11,7 +11,8 @@ const EmptyPercentage = "NaN" const ( PrettyFormat string = "pretty-printer" JsonFormat string = "json" - JunitResultPrinter string = "junit" + JunitResultFormat string = "junit" + PrometheusFormat string = "prometheus" ) type IPrinter interface { @@ -24,8 +25,10 @@ func GetPrinter(printFormat string) IPrinter { switch printFormat { case JsonFormat: return NewJsonPrinter() - case JunitResultPrinter: + case JunitResultFormat: return NewJunitPrinter() + case PrometheusFormat: + return NewPrometheusPrinter() default: return NewPrettyPrinter() } diff --git a/resultshandling/printer/prometheusprinter.go b/resultshandling/printer/prometheusprinter.go new file mode 100644 index 00000000..0c5bbaaf --- /dev/null +++ b/resultshandling/printer/prometheusprinter.go @@ -0,0 +1,100 @@ +package printer + +import ( + "errors" + "fmt" + "os" + + "github.com/armosec/kubescape/cautils" + "github.com/armosec/opa-utils/reporthandling" +) + +type PrometheusPrinter struct { + writer *os.File +} + +func NewPrometheusPrinter() *PrometheusPrinter { + return &PrometheusPrinter{} +} + +func (prometheusPrinter *PrometheusPrinter) SetWriter(outputFile string) { + prometheusPrinter.writer = getWriter(outputFile) +} + +func (prometheusPrinter *PrometheusPrinter) Score(score float32) { + fmt.Printf("\n# Overall score out of 100\nkubescape_score %f\n", score*100) +} + +func (printer *PrometheusPrinter) printDetails(details []reporthandling.RuleResponse, frameworkName string, controlName string) error { + objs := make(map[string]map[string]map[string]int) + for _, ruleResponses := range details { + for _, k8sObj := range ruleResponses.AlertObject.K8SApiObjects { + kind, ok := k8sObj[`kind`].(string) + if (!ok) { + return errors.New("Found object with non string kind") + } + apiVersion,ok := k8sObj[`apiVersion`].(string) + if (!ok) { + return errors.New("Found object with non string apiVersion") + } + gvk := fmt.Sprintf("%s/%s",apiVersion,kind) + metadata,ok := k8sObj[`metadata`].(map[string]interface{}) + if (!ok) { + return errors.New("Found object with non convertable metadata") + } + name,ok := metadata[`name`].(string) + if (!ok) { + return errors.New("Found metadata with non string name") + } + namespace,ok := metadata[`namespace`].(string) + if (!ok) { + namespace = "" + } + if (objs[gvk] == nil) { + objs[gvk] = make(map[string]map[string]int) + } + if (objs[gvk][namespace] == nil) { + objs[gvk][namespace] = make(map[string]int) + } + objs[gvk][namespace][name]++ + } + } + for gvk, namespaces := range objs { + for namespace, names := range namespaces { + for name, value := range names { + fmt.Fprintf(printer.writer, "# Failed object from %s control %s\n", frameworkName, controlName) + if namespace != "" { + fmt.Fprintf(printer.writer, "kubescape_object_failed_count{framework=\"%s\",control=\"%s\",namespace=\"%s\",name=\"%s\",groupVersionKind=\"%s\"} %d\n", frameworkName, controlName, namespace, name, gvk, value) + } else { + fmt.Fprintf(printer.writer, "kubescape_object_failed_count{framework=\"%s\",control=\"%s\",name=\"%s\",groupVersionKind=\"%s\"} %d\n", frameworkName, controlName, name, gvk, value) + } + } + } + } + return nil +} + +func (printer *PrometheusPrinter) printReports(frameworks []reporthandling.FrameworkReport) error { + for _, framework := range frameworks { + for _, controlReports := range framework.ControlReports { + if len(controlReports.RuleReports[0].RuleResponses) > 0 { + fmt.Fprintf(printer.writer, "# Number of resources found as part of %s control %s\nkubescape_resources_found_count{framework=\"%s\",control=\"%s\"} %d\n", framework.Name, controlReports.Name, framework.Name, controlReports.Name, controlReports.GetNumberOfResources()) + fmt.Fprintf(printer.writer, "# Number of resources excluded as part of %s control %s\nkubescape_resources_excluded_count{framework=\"%s\",control=\"%s\"} %d\n", framework.Name, controlReports.Name, framework.Name, controlReports.Name, controlReports.GetNumberOfWarningResources()) + fmt.Fprintf(printer.writer, "# Number of resources failed as part of %s control %s\nkubescape_resources_failed_count{framework=\"%s\",control=\"%s\"} %d\n", framework.Name, controlReports.Name, framework.Name, controlReports.Name, controlReports.GetNumberOfFailedResources()) + err := printer.printDetails(controlReports.RuleReports[0].RuleResponses, framework.Name, controlReports.Name) + if err != nil { + return err + } + } + } + } + return nil +} + +func (printer *PrometheusPrinter) ActionPrint(opaSessionObj *cautils.OPASessionObj) { + err := printer.printReports(opaSessionObj.PostureReport.FrameworkReports) + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} From 8e67104ba4396ca44c4a6370763cabc5a58c3c56 Mon Sep 17 00:00:00 2001 From: Alan Clucas Date: Tue, 26 Oct 2021 16:23:05 +0100 Subject: [PATCH 15/70] Add prometheus to readme --- README.md | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6bcc3cb6..eabf319e 100644 --- a/README.md +++ b/README.md @@ -69,19 +69,19 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser ## Flags -| flag | default | description | options | -| --- | --- | --- | --- | -| `-e`/`--exclude-namespaces` | Scan all namespaces | Namespaces to exclude from scanning. Recommended to exclude `kube-system` and `kube-public` namespaces | -| `-s`/`--silent` | Display progress messages | Silent progress messages | -| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result bellow threshold| `0` -> `100` | -| `-f`/`--format` | `pretty-printer` | Output format | `pretty-printer`/`json`/`junit` | -| `-o`/`--output` | print to stdout | Save scan result in file | -| `--use-from` | | Load local framework object from specified path. If not used will download latest | -| `--use-default` | `false` | Load local framework object from default path. If not used will download latest | `true`/`false` | -| `--exceptions` | | Path to an [exceptions obj](examples/exceptions.json). If not set will download exceptions from Armo management portal | -| `--submit` | `false` | If set, Kubescape will send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not sent | `true`/`false`| -| `--keep-local` | `false` | Kubescape will not send scan results to Armo management portal. Use this flag if you ran with the `--submit` flag in the past and you do not want to submit your current scan results | `true`/`false`| -| `--account` | | Armo portal account ID. Default will load account ID from configMap or config file | | +| flag | default | description | options | +|-----------------------------|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------| +| `-e`/`--exclude-namespaces` | Scan all namespaces | Namespaces to exclude from scanning. Recommended to exclude `kube-system` and `kube-public` namespaces | | +| `-s`/`--silent` | Display progress messages | Silent progress messages | | +| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result bellow threshold | `0` -> `100` | +| `-f`/`--format` | `pretty-printer` | Output format | `pretty-printer`/`json`/`junit`/`prometheus` | +| `-o`/`--output` | print to stdout | Save scan result in file | | +| `--use-from` | | Load local framework object from specified path. If not used will download latest | | +| `--use-default` | `false` | Load local framework object from default path. If not used will download latest | `true`/`false` | +| `--exceptions` | | Path to an [exceptions obj](examples/exceptions.json). If not set will download exceptions from Armo management portal | | +| `--submit` | `false` | If set, Kubescape will send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not sent | `true`/`false` | +| `--keep-local` | `false` | Kubescape will not send scan results to Armo management portal. Use this flag if you ran with the `--submit` flag in the past and you do not want to submit your current scan results | `true`/`false` | +| `--account` | | Armo portal account ID. Default will load account ID from configMap or config file | | ## Usage & Examples @@ -125,6 +125,11 @@ kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --form kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --format junit --output results.xml ``` +* Output in `prometheus` metrics format +``` +kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --format prometheus +``` + * Scan with exceptions, objects with exceptions will be presented as `exclude` and not `fail` ``` kubescape scan framework nsa --exceptions examples/exceptions.json From 8a7cda5dd1111cdece4b0252642c1839a28b9e77 Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 26 Oct 2021 20:27:33 +0300 Subject: [PATCH 16/70] adopt cluster name --- cautils/customerloader.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cautils/customerloader.go b/cautils/customerloader.go index 44b79930..c77347f0 100644 --- a/cautils/customerloader.go +++ b/cautils/customerloader.go @@ -243,7 +243,7 @@ func (c *ClusterConfig) setCustomerGUID(customerGUID string) { } func (c *ClusterConfig) setClusterName(clusterName string) { - c.configObj.ClusterName = clusterName + c.configObj.ClusterName = adoptClusterName(clusterName) } func (c *ClusterConfig) GetClusterName() string { return c.configObj.ClusterName @@ -472,3 +472,7 @@ func DeleteConfigMap(k8s *k8sinterface.KubernetesApi) error { func DeleteConfigFile() error { return os.Remove(ConfigFileFullPath()) } + +func adoptClusterName(clusterName string) string { + return strings.ReplaceAll(clusterName, "/", "-") +} From 66068757e1b5a78b3fa79b2e62848a3953312285 Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 26 Oct 2021 20:39:18 +0300 Subject: [PATCH 17/70] update cluster name in mock struct --- cautils/customerloader.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cautils/customerloader.go b/cautils/customerloader.go index c77347f0..dce4a9db 100644 --- a/cautils/customerloader.go +++ b/cautils/customerloader.go @@ -129,7 +129,7 @@ func (c *EmptyConfig) GetCustomerGUID() string { return "" } func (c *EmptyConfig) GetK8sAPI() *k8sinterface.KubernetesApi { return nil } // TODO: return mock obj func (c *EmptyConfig) GetDefaultNS() string { return k8sinterface.GetDefaultNamespace() } func (c *EmptyConfig) GetBackendAPI() getter.IBackend { return nil } // TODO: return mock obj -func (c *EmptyConfig) GetClusterName() string { return k8sinterface.GetClusterName() } +func (c *EmptyConfig) GetClusterName() string { return adoptClusterName(k8sinterface.GetClusterName()) } func (c *EmptyConfig) GenerateURL() { message := fmt.Sprintf("\nCheckout for more cool features: https://%s\n", getter.GetArmoAPIConnector().GetFrontendURL()) InfoTextDisplay(os.Stdout, fmt.Sprintf("\n%s\n", message)) From 151175c40f2e2189f797537131ea1eaf030663d4 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Wed, 27 Oct 2021 08:49:40 +0300 Subject: [PATCH 18/70] read single control from framework file --- cautils/getter/loadpolicy.go | 13 +++++++++++-- clihandler/cmd/control.go | 15 +++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cautils/getter/loadpolicy.go b/cautils/getter/loadpolicy.go index 223ee7ec..87d01fa5 100644 --- a/cautils/getter/loadpolicy.go +++ b/cautils/getter/loadpolicy.go @@ -38,9 +38,18 @@ func (lp *LoadPolicy) GetControl(controlName string) (*reporthandling.Control, e if err = json.Unmarshal(f, control); err != nil { return control, err } - if controlName != "" && !strings.EqualFold(controlName, control.Name) && !strings.EqualFold(controlName, control.ControlID) { - return nil, fmt.Errorf("control from file not matching") + framework, err := lp.GetFramework(control.PortalBase.Name) + if err != nil { + return nil, fmt.Errorf("control from file not matching") + } else { + for _, ctrl := range framework.Controls { + if strings.EqualFold(ctrl.Name, controlName) || strings.EqualFold(ctrl.ControlID, controlName) { + control = &ctrl + break + } + } + } } return control, err } diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index 967906a1..585f481d 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -33,12 +33,15 @@ var controlCmd = &cobra.Command{ if len(args) < 1 && !(cmd.Flags().Lookup("use-from").Changed) { scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) } else { - controls := strings.Split(args[0], ",") - scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindControl - newPolicy.Name = controls[0] - scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + var controls []string + if len(args) > 0 { + controls = strings.Split(args[0], ",") + scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindControl + newPolicy.Name = controls[0] + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + } if !(cmd.Flags().Lookup("use-from").Changed) { if len(controls) > 1 { From 3301907864d2c9491a54ef6bba76d3babf6f9882 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Wed, 27 Oct 2021 10:25:26 +0300 Subject: [PATCH 19/70] print only one table for controls & enhance help msg --- clihandler/cmd/control.go | 6 +-- clihandler/cmd/framework.go | 2 +- policyhandler/handlepullpolicies.go | 63 ++++++++++++++---------- resultshandling/printer/prettyprinter.go | 11 +++-- 4 files changed, 49 insertions(+), 33 deletions(-) diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index 585f481d..f5911b1d 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -13,7 +13,7 @@ import ( // controlCmd represents the control command var controlCmd = &cobra.Command{ - Use: "control /", + Use: "control /.\nExamples:\nscan control C-0058,C-0057 [flags]\nscan contol C-0058 [flags]\nscan control 'privileged container,allowed hostpath' [flags]", Short: fmt.Sprintf("The control you wish to use for scan. It must be present in at least one of the folloiwng frameworks: %s", clihandler.ValidFrameworks), Args: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -75,9 +75,7 @@ func flagValidationControl() { func SetScanForGivenControls(controls []string) []reporthandling.PolicyIdentifier { for _, control := range controls { - if control[0] == ' ' { - control = control[1:] - } + control := strings.TrimLeft(control, " ") newPolicy := reporthandling.PolicyIdentifier{} newPolicy.Kind = reporthandling.KindControl newPolicy.Name = control diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 4110f028..0b7d0dbe 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -13,7 +13,7 @@ import ( ) var frameworkCmd = &cobra.Command{ - Use: fmt.Sprintf("framework [``/`-`] [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), + Use: fmt.Sprintf("framework [``/`-`] [flags]\nExamples:\nscan framework nsa [flags]\nscanframework mitre,nsa [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), Short: fmt.Sprintf("The framework you wish to use. Supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", ")), Long: "Execute a scan on a running Kubernetes cluster or `yaml`/`json` files (use glob) or `-` for stdin", ValidArgs: clihandler.SupportedFrameworks, diff --git a/policyhandler/handlepullpolicies.go b/policyhandler/handlepullpolicies.go index 0762a05e..579c05db 100644 --- a/policyhandler/handlepullpolicies.go +++ b/policyhandler/handlepullpolicies.go @@ -14,9 +14,11 @@ func (policyHandler *PolicyHandler) GetPoliciesFromBackend(notification *reporth frameworks := []reporthandling.Framework{} exceptionPolicies := []armotypes.PostureExceptionPolicy{} // Get - cacli opa get - for _, rule := range notification.Rules { - switch rule.Kind { - case reporthandling.KindFramework: + rule := GetScanKind(notification) + + switch rule.Kind { + case reporthandling.KindFramework: + for _, rule := range notification.Rules { receivedFramework, recExceptionPolicies, err := policyHandler.getFrameworkPolicies(rule.Name) if receivedFramework != nil { frameworks = append(frameworks, *receivedFramework) @@ -29,27 +31,32 @@ func (policyHandler *PolicyHandler) GetPoliciesFromBackend(notification *reporth } return nil, nil, fmt.Errorf("kind: %v, name: %s, error: %s", rule.Kind, rule.Name, err.Error()) } - case reporthandling.KindControl: - receivedControls, recExceptionPolicies, err := policyHandler.getControl(rule.Name) - if receivedControls != nil { - f := reporthandling.Framework{ - Controls: receivedControls, - } - frameworks = append(frameworks, f) + } + case reporthandling.KindControl: + f := reporthandling.Framework{} + var receivedControl *reporthandling.Control + var recExceptionPolicies []armotypes.PostureExceptionPolicy + var err error + for _, rule := range notification.Rules { + receivedControl, recExceptionPolicies, err = policyHandler.getControl(rule.Name) + if receivedControl != nil { + f.Controls = append(f.Controls, *receivedControl) if recExceptionPolicies != nil { exceptionPolicies = append(exceptionPolicies, recExceptionPolicies...) } + } else if err != nil { if strings.Contains(err.Error(), "unsupported protocol scheme") { err = fmt.Errorf("failed to download from GitHub release, try running with `--use-default` flag") } return nil, nil, fmt.Errorf("error: %s", err.Error()) } - // TODO: add case for control from file - default: - err := fmt.Errorf("missing rule kind, expected: %s", reporthandling.KindFramework) - errs = fmt.Errorf("%s", err.Error()) } + frameworks = append(frameworks, f) + // TODO: add case for control from file + default: + err := fmt.Errorf("missing rule kind, expected: %s", reporthandling.KindFramework) + errs = fmt.Errorf("%s", err.Error()) } return frameworks, exceptionPolicies, errs } @@ -68,24 +75,30 @@ func (policyHandler *PolicyHandler) getFrameworkPolicies(policyName string) (*re return receivedFramework, receivedException, nil } +func GetScanKind(notification *reporthandling.PolicyNotification) *reporthandling.PolicyIdentifier { + if len(notification.Rules) > 0 { + return ¬ification.Rules[0] + } + return nil +} + // Get control by name -func (policyHandler *PolicyHandler) getControl(policyName string) ([]reporthandling.Control, []armotypes.PostureExceptionPolicy, error) { +func (policyHandler *PolicyHandler) getControl(policyName string) (*reporthandling.Control, []armotypes.PostureExceptionPolicy, error) { - controls := []reporthandling.Control{} - - control, err := policyHandler.getters.PolicyGetter.GetControl(policyName) + control := &reporthandling.Control{} + var err error + control, err = policyHandler.getters.PolicyGetter.GetControl(policyName) if err != nil { - return nil, nil, err + return control, nil, err } - if control == nil { - return nil, nil, fmt.Errorf("control not found") - } - controls = append(controls, *control) + // if control == nil { + // return control, nil, fmt.Errorf("control not found") + // } exceptions, err := policyHandler.getters.ExceptionsGetter.GetExceptions(cautils.CustomerGUID, cautils.ClusterName) if err != nil { - return controls, nil, err + return control, nil, err } - return controls, exceptions, nil + return control, exceptions, nil } diff --git a/resultshandling/printer/prettyprinter.go b/resultshandling/printer/prettyprinter.go index 0752fa00..225b6844 100644 --- a/resultshandling/printer/prettyprinter.go +++ b/resultshandling/printer/prettyprinter.go @@ -192,9 +192,8 @@ func generateFooter(numControlers, sumFailed, sumWarning, sumTotal int) []string } func (printer *PrettyPrinter) printSummaryTable(framework string) { // For control scan framework will be nil - if framework != "" { - cautils.InfoTextDisplay(printer.writer, fmt.Sprintf("%s FRAMEWORK\n", framework)) - } + printer.printFramework(framework) + summaryTable := tablewriter.NewWriter(printer.writer) summaryTable.SetAutoWrapText(false) summaryTable.SetHeader(generateHeader()) @@ -210,6 +209,12 @@ func (printer *PrettyPrinter) printSummaryTable(framework string) { summaryTable.Render() } +func (printer *PrettyPrinter) printFramework(framework string) { + if framework != "" { + cautils.InfoTextDisplay(printer.writer, fmt.Sprintf("%s FRAMEWORK\n", framework)) + } +} + func (printer *PrettyPrinter) getSortedControlsNames() []string { controlNames := make([]string, 0, len(printer.summary)) for k := range printer.summary { From 5095e62961b49c4e7f8133d462f6eb3ed675b27c Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Wed, 27 Oct 2021 13:02:45 +0300 Subject: [PATCH 20/70] support scanning multiple frameworks from multiple files --- cautils/getter/loadpolicy.go | 42 ++++++++++++++++++++++++------------ cautils/scaninfo.go | 13 ++++++----- clihandler/cmd/framework.go | 22 ++++++++++--------- clihandler/cmd/scan.go | 2 +- 4 files changed, 47 insertions(+), 32 deletions(-) diff --git a/cautils/getter/loadpolicy.go b/cautils/getter/loadpolicy.go index 87d01fa5..cd667b5b 100644 --- a/cautils/getter/loadpolicy.go +++ b/cautils/getter/loadpolicy.go @@ -17,12 +17,12 @@ const DefaultLocalStore = ".kubescape" // Load policies from a local repository type LoadPolicy struct { - filePath string + filePaths []string } -func NewLoadPolicy(filePath string) *LoadPolicy { +func NewLoadPolicy(filePaths []string) *LoadPolicy { return &LoadPolicy{ - filePath: filePath, + filePaths: filePaths, } } @@ -30,7 +30,8 @@ func NewLoadPolicy(filePath string) *LoadPolicy { func (lp *LoadPolicy) GetControl(controlName string) (*reporthandling.Control, error) { control := &reporthandling.Control{} - f, err := os.ReadFile(lp.filePath) + filePath := lp.getFileForControl() + f, err := os.ReadFile(filePath) if err != nil { return nil, err } @@ -55,27 +56,32 @@ func (lp *LoadPolicy) GetControl(controlName string) (*reporthandling.Control, e } func (lp *LoadPolicy) GetFramework(frameworkName string) (*reporthandling.Framework, error) { - framework := &reporthandling.Framework{} - f, err := os.ReadFile(lp.filePath) - if err != nil { - return nil, err - } + var err error + for _, filePath := range lp.filePaths { + f, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } - if err = json.Unmarshal(f, framework); err != nil { - return framework, err + if err = json.Unmarshal(f, framework); err != nil { + return framework, err + } + if strings.EqualFold(frameworkName, framework.Name) { + break + } } - if frameworkName != "" && !strings.EqualFold(frameworkName, framework.Name) { + return nil, fmt.Errorf("framework from file not matching") } return framework, err } func (lp *LoadPolicy) GetExceptions(customerGUID, clusterName string) ([]armotypes.PostureExceptionPolicy, error) { - + filePath := lp.getFileForException() exception := []armotypes.PostureExceptionPolicy{} - f, err := os.ReadFile(lp.filePath) + f, err := os.ReadFile(filePath) if err != nil { return nil, err } @@ -83,3 +89,11 @@ func (lp *LoadPolicy) GetExceptions(customerGUID, clusterName string) ([]armotyp err = json.Unmarshal(f, &exception) return exception, err } + +func (lp *LoadPolicy) getFileForException() string { + return lp.filePaths[0] +} + +func (lp *LoadPolicy) getFileForControl() string { + return lp.filePaths[0] +} diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index 6c6c31fa..ca0b064d 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -11,7 +11,7 @@ type ScanInfo struct { Getters PolicyIdentifier []reporthandling.PolicyIdentifier UseExceptions string // Load exceptions configuration - UseFrom string // Load framework from local file (instead of download). Use when running offline + UseFrom []string // Load framework from local file (instead of download). Use when running offline UseDefault bool // Load framework from cached file (instead of download). Use when running offline Format string // Format results (table, json, junit ...) Output string // Store results in an output file, Output file name @@ -41,22 +41,21 @@ func (scanInfo *ScanInfo) Init() { func (scanInfo *ScanInfo) setUseExceptions() { if scanInfo.UseExceptions != "" { // load exceptions from file - scanInfo.ExceptionsGetter = getter.NewLoadPolicy(scanInfo.UseExceptions) + scanInfo.ExceptionsGetter = getter.NewLoadPolicy([]string{scanInfo.UseExceptions}) } else { scanInfo.ExceptionsGetter = getter.GetArmoAPIConnector() } } func (scanInfo *ScanInfo) setUseFrom() { - if scanInfo.UseFrom != "" { - return - } if scanInfo.UseDefault { - scanInfo.UseFrom = getter.GetDefaultPath(scanInfo.PolicyIdentifier[0].Name + ".json") + for _, policy := range scanInfo.PolicyIdentifier { + scanInfo.UseFrom = append(scanInfo.UseFrom, getter.GetDefaultPath(policy.Name+".json")) + } } } func (scanInfo *ScanInfo) setGetter() { - if scanInfo.UseFrom != "" { + if len(scanInfo.UseFrom) > 0 { // load from file scanInfo.PolicyGetter = getter.NewLoadPolicy(scanInfo.UseFrom) } else { diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 0b7d0dbe..bac9d768 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -37,16 +37,11 @@ var frameworkCmd = &cobra.Command{ scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) } else { // Read frameworks from input args - frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindFramework - newPolicy.Name = frameworks[0] - scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) - if !(cmd.Flags().Lookup("use-from").Changed) && !(cmd.Flags().Lookup("use-default").Changed) { - if len(frameworks) > 1 { - scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(frameworks[1:]) - } + frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") + scanInfo.PolicyIdentifier = SetScanForFirstFramework(frameworks) + if len(frameworks) > 1 { + scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(frameworks[1:]) } } if len(args) > 0 { @@ -88,7 +83,6 @@ func init() { frameworkCmd.Flags().BoolVarP(&scanInfo.Local, "keep-local", "", false, "If you do not want your Kubescape results reported to Armo backend. Use this flag if you ran with the '--submit' flag in the past and you do not want to submit your current scan results") frameworkCmd.Flags().StringVarP(&scanInfo.Account, "account", "", "", "Armo portal account ID. Default will load account ID from configMap or config file") } - func SetScanForGivenFrameworks(frameworks []string) []reporthandling.PolicyIdentifier { for _, framework := range frameworks { newPolicy := reporthandling.PolicyIdentifier{} @@ -99,6 +93,14 @@ func SetScanForGivenFrameworks(frameworks []string) []reporthandling.PolicyIdent return scanInfo.PolicyIdentifier } +func SetScanForFirstFramework(frameworks []string) []reporthandling.PolicyIdentifier { + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindFramework + newPolicy.Name = frameworks[0] + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + return scanInfo.PolicyIdentifier +} + func flagValidationFramework() { if scanInfo.Submit && scanInfo.Local { fmt.Println("You can use `keep-local` or `submit`, but not both") diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index 9b0e46bb..3b16fd0d 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -40,7 +40,7 @@ func init() { scanCmd.PersistentFlags().StringVarP(&scanInfo.Output, "output", "o", "", "Output file. Print output to file and not stdout") scanCmd.PersistentFlags().BoolVarP(&scanInfo.Silent, "silent", "s", false, "Silent progress messages") scanCmd.PersistentFlags().Uint16VarP(&scanInfo.FailThreshold, "fail-threshold", "t", 0, "Failure threshold is the percent bellow which the command fails and returns exit code 1") - scanCmd.PersistentFlags().StringVar(&scanInfo.UseFrom, "use-from", "", "Load local framework object from specified path. If not used will download latest") + scanCmd.PersistentFlags().StringSliceVar(&scanInfo.UseFrom, "use-from", nil, "Load local framework object from specified path. If not used will download latest") scanCmd.PersistentFlags().BoolVar(&scanInfo.UseDefault, "use-default", false, "Load local framework object from default path. If not used will download latest") scanCmd.PersistentFlags().StringVar(&scanInfo.UseExceptions, "exceptions", "", "Path to an exceptions obj. If not set will download exceptions from Armo management portal") } From 9439f407da7e7ae449efdd3e7eb79a233d9bd522 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Wed, 27 Oct 2021 13:39:03 +0300 Subject: [PATCH 21/70] add env var to not check latest release --- main.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index dd5e7bff..84329f92 100644 --- a/main.go +++ b/main.go @@ -5,23 +5,25 @@ import ( "os" "github.com/armosec/kubescape/clihandler/cmd" + pkgutils "github.com/armosec/utils-go/utils" ) +const SKIP_VERSION_CHECK = "KUBESCAPE_SKIP_UPDATE_CHECK" + func main() { CheckLatestVersion() cmd.Execute() } func CheckLatestVersion() { + if v, ok := os.LookupEnv(SKIP_VERSION_CHECK); ok && pkgutils.StringToBool(v) { + return + } latest, err := cmd.GetLatestVersion() - if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - } else if latest != cmd.BuildNumber { - if latest == "unknown" { - fmt.Println("Warning: You are not updated to the latest release!") - } else { - fmt.Println("Warning: You are not updated to the latest release: " + latest) - } + if err != nil || latest == "unknown" { + return + } + if latest != cmd.BuildNumber { + fmt.Println("Warning: You are not updated to the latest release: " + latest) } - } From de8bcfa0d2444e463de05eeb565ac92bfc93f6dd Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Wed, 27 Oct 2021 14:44:25 +0300 Subject: [PATCH 22/70] enhance help msgs --- cautils/getter/loadpolicy.go | 2 +- clihandler/cmd/control.go | 25 +++++++++++++++---------- clihandler/cmd/framework.go | 6 ++++-- clihandler/cmd/scan.go | 4 ++-- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/cautils/getter/loadpolicy.go b/cautils/getter/loadpolicy.go index cd667b5b..e71451d5 100644 --- a/cautils/getter/loadpolicy.go +++ b/cautils/getter/loadpolicy.go @@ -40,7 +40,7 @@ func (lp *LoadPolicy) GetControl(controlName string) (*reporthandling.Control, e return control, err } if controlName != "" && !strings.EqualFold(controlName, control.Name) && !strings.EqualFold(controlName, control.ControlID) { - framework, err := lp.GetFramework(control.PortalBase.Name) + framework, err := lp.GetFramework(controlName) if err != nil { return nil, fmt.Errorf("control from file not matching") } else { diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index f5911b1d..18a18853 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -13,7 +13,7 @@ import ( // controlCmd represents the control command var controlCmd = &cobra.Command{ - Use: "control /.\nExamples:\nscan control C-0058,C-0057 [flags]\nscan contol C-0058 [flags]\nscan control 'privileged container,allowed hostpath' [flags]", + Use: "control /.\nExamples:\n$ kubescape scan control C-0058,C-0057 [flags]\n$ kubescape scan contol C-0058 [flags]\n$ kubescape scan control 'privileged container,allowed hostpath' [flags]", Short: fmt.Sprintf("The control you wish to use for scan. It must be present in at least one of the folloiwng frameworks: %s", clihandler.ValidFrameworks), Args: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -23,6 +23,8 @@ var controlCmd = &cobra.Command{ return fmt.Errorf("usage: ,") } } + } else { + return fmt.Errorf("requires at least one control name") } return nil }, @@ -30,23 +32,18 @@ var controlCmd = &cobra.Command{ flagValidationControl() scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - if len(args) < 1 && !(cmd.Flags().Lookup("use-from").Changed) { + if len(args) < 1 { scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) } else { var controls []string if len(args) > 0 { controls = strings.Split(args[0], ",") scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindControl - newPolicy.Name = controls[0] - scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + scanInfo.PolicyIdentifier = setScanForFirstControl(controls) } - if !(cmd.Flags().Lookup("use-from").Changed) { - if len(controls) > 1 { - scanInfo.PolicyIdentifier = SetScanForGivenControls(controls[1:]) - } + if len(controls) > 1 { + scanInfo.PolicyIdentifier = SetScanForGivenControls(controls[1:]) } } scanInfo.FrameworkScan = false @@ -73,6 +70,14 @@ func flagValidationControl() { } } +func setScanForFirstControl(controls []string) []reporthandling.PolicyIdentifier { + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindControl + newPolicy.Name = controls[0] + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + return scanInfo.PolicyIdentifier +} + func SetScanForGivenControls(controls []string) []reporthandling.PolicyIdentifier { for _, control := range controls { control := strings.TrimLeft(control, " ") diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index bac9d768..9b75e224 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -13,7 +13,7 @@ import ( ) var frameworkCmd = &cobra.Command{ - Use: fmt.Sprintf("framework [``/`-`] [flags]\nExamples:\nscan framework nsa [flags]\nscanframework mitre,nsa [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), + Use: fmt.Sprintf("framework [``/`-`] [flags]\nExamples:\n$ kubescape scan framework nsa [flags]\n$ kubescape scan framework mitre,nsa [flags]\n$ kubescape scan framework 'nsa, mitre' [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), Short: fmt.Sprintf("The framework you wish to use. Supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", ")), Long: "Execute a scan on a running Kubernetes cluster or `yaml`/`json` files (use glob) or `-` for stdin", ValidArgs: clihandler.SupportedFrameworks, @@ -26,6 +26,8 @@ var frameworkCmd = &cobra.Command{ return fmt.Errorf(fmt.Sprintf("supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", "))) } } + } else { + return fmt.Errorf("requires at least one framework name") } return nil }, @@ -33,7 +35,7 @@ var frameworkCmd = &cobra.Command{ flagValidationFramework() scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} // If no framework provided, use all - if len(args) < 1 && !(cmd.Flags().Lookup("use-from").Changed) { + if len(args) < 1 { scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) } else { // Read frameworks from input args diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index 3b16fd0d..f9c2e65b 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -40,7 +40,7 @@ func init() { scanCmd.PersistentFlags().StringVarP(&scanInfo.Output, "output", "o", "", "Output file. Print output to file and not stdout") scanCmd.PersistentFlags().BoolVarP(&scanInfo.Silent, "silent", "s", false, "Silent progress messages") scanCmd.PersistentFlags().Uint16VarP(&scanInfo.FailThreshold, "fail-threshold", "t", 0, "Failure threshold is the percent bellow which the command fails and returns exit code 1") - scanCmd.PersistentFlags().StringSliceVar(&scanInfo.UseFrom, "use-from", nil, "Load local framework object from specified path. If not used will download latest") - scanCmd.PersistentFlags().BoolVar(&scanInfo.UseDefault, "use-default", false, "Load local framework object from default path. If not used will download latest") + scanCmd.PersistentFlags().StringSliceVar(&scanInfo.UseFrom, "use-from", nil, "Load local policy object from specified path. If not used will download latest") + scanCmd.PersistentFlags().BoolVar(&scanInfo.UseDefault, "use-default", false, "Load local policy object from default path. If not used will download latest") scanCmd.PersistentFlags().StringVar(&scanInfo.UseExceptions, "exceptions", "", "Path to an exceptions obj. If not set will download exceptions from Armo management portal") } From 40acfb5e9da9d775f556089093a5c9e41a05bdf8 Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 28 Oct 2021 09:10:37 +0300 Subject: [PATCH 23/70] Adding cronJob doc --- README.md | 4 + examples/cronJob-suport/README.md | 81 ++++++++++++++++++ examples/cronJob-suport/ks-configMap.yaml | 14 +++ .../cronJob-suport/ks-cronJob-non-submit.yaml | 32 +++++++ .../cronJob-suport/ks-cronJob-submit.yaml | 40 +++++++++ examples/cronJob-suport/ks-namespace.yaml | 7 ++ .../cronJob-suport/ks-serviceAccount.yaml | 61 +++++++++++++ .../cronJob-suport/screenshots/account.png | Bin 0 -> 41707 bytes .../screenshots/add-cluster.png | Bin 0 -> 122365 bytes examples/cronjob/ks-cronjob.yaml | 7 ++ 10 files changed, 246 insertions(+) create mode 100644 examples/cronJob-suport/README.md create mode 100644 examples/cronJob-suport/ks-configMap.yaml create mode 100644 examples/cronJob-suport/ks-cronJob-non-submit.yaml create mode 100644 examples/cronJob-suport/ks-cronJob-submit.yaml create mode 100644 examples/cronJob-suport/ks-namespace.yaml create mode 100644 examples/cronJob-suport/ks-serviceAccount.yaml create mode 100644 examples/cronJob-suport/screenshots/account.png create mode 100644 examples/cronJob-suport/screenshots/add-cluster.png diff --git a/README.md b/README.md index 6bcc3cb6..f0f5f128 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --form kubescape scan framework nsa --exceptions examples/exceptions.json ``` +### Repeatedly Kubescape Scanning using a CronJob + +For setting up a cronJob please follow the [instructions](examples/cronJob-suport/README.md) + ### Helm Support * Render the helm chart using [`helm template`](https://helm.sh/docs/helm/helm_template/) and pass to stdout diff --git a/examples/cronJob-suport/README.md b/examples/cronJob-suport/README.md new file mode 100644 index 00000000..5a901ba0 --- /dev/null +++ b/examples/cronJob-suport/README.md @@ -0,0 +1,81 @@ +# Repeatedly Kubescape Scanning + +You can scan your cluster repeatedly by adding a cronJob that will repeatedly trigger kubescape + +## Repeatedly Scanning & Submitting + +If you wish to repeatedly scan and submit the result to the [SaaS UI](https://portal.armo.cloud/) where you can benefit the features the SaaS version provides, please follow this instructions -> + +1. Apply kubescape namespace + ``` + kubectl apply ks-namespace.yaml + ``` + +2. Apply serviceAccount and roles + ``` + kubectl apply ks-serviceAccount.yaml + ``` + +3. Setup and apply configMap + + Before you apply the configMap you need to set the account ID and cluster name in the `ks-configMap.yaml` file. + + * Set cluster name: + Run `kubectl config current-context` and set the result in the `data.clusterName` field + * Set account ID: + 1. Navigate to the [SaaS UI](https://portal.armo.cloud/) and login/sign up for free + 2. Click the `Add Cluster` button on the top right of the page + add-cluster + 3. Copy the value of `--account` and set it in the `data.customerGUID` field + account + + Make sure the configMap looks as following; + ``` + kind: ConfigMap + apiVersion: v1 + metadata: + name: kubescape + labels: + app: kubescape + namespace: kubescape + data: + config.json: | + { + "customerGUID": "XXXXXXXX-XXXX-XXXX-XXXXXXXXXXXX", + "clusterName": "my-awesome-cluster-name" + } + ``` + + Finally, apply the configMap + ``` + kubectl apply ks-configMap.yaml + ``` + +4. Apply cronJob + Before you apply the cronJob, make sure the scanning frequency suites your needs + + ``` + kubectl apply ks-cronJob-submit.yaml + ``` + +## Repeatedly Scanning Without Submitting + +If you wish to repeatedly scan but not submit the scan results, follow this instructions -> + +1. Apply kubescape namespace + ``` + kubectl apply ks-namespace.yaml + ``` + +2. Apply serviceAccount and roles + ``` + kubectl apply ks-serviceAccount.yaml + ``` + +3. Apply cronJob + + Before you apply the cronJob, make sure the scanning frequency suites your needs + + ``` + kubectl apply ks-cronJob-non-submit.yaml + ``` \ No newline at end of file diff --git a/examples/cronJob-suport/ks-configMap.yaml b/examples/cronJob-suport/ks-configMap.yaml new file mode 100644 index 00000000..59aaf979 --- /dev/null +++ b/examples/cronJob-suport/ks-configMap.yaml @@ -0,0 +1,14 @@ +# ------------------- Kubescape User/Customer ID ------------------- # +kind: ConfigMap +apiVersion: v1 +metadata: + name: kubescape + labels: + app: kubescape + namespace: kubescape +data: + config.json: | + { + "customerGUID": "", + "clusterName": "" + } \ No newline at end of file diff --git a/examples/cronJob-suport/ks-cronJob-non-submit.yaml b/examples/cronJob-suport/ks-cronJob-non-submit.yaml new file mode 100644 index 00000000..ec7f1ab1 --- /dev/null +++ b/examples/cronJob-suport/ks-cronJob-non-submit.yaml @@ -0,0 +1,32 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: kubescape + labels: + app: kubescape + namespace: kubescape +spec: + # ┌────────────────── timezone (optional) + # | ┌───────────── minute (0 - 59) + # | │ ┌───────────── hour (0 - 23) + # | │ │ ┌───────────── day of the month (1 - 31) + # | │ │ │ ┌───────────── month (1 - 12) + # | │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday; + # | │ │ │ │ │ 7 is also Sunday on some systems) + # | │ │ │ │ │ + # | │ │ │ │ │ + # CRON_TZ=UTC * * * * * + schedule: "0 0 1 * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: kubescape + image: quay.io/armosec/kubescape:latest + imagePullPolicy: IfNotPresent + command: ["/bin/sh","-c"] + args: + - kubescape scan framework nsa + restartPolicy: OnFailure + serviceAccountName: kubescape-discovery diff --git a/examples/cronJob-suport/ks-cronJob-submit.yaml b/examples/cronJob-suport/ks-cronJob-submit.yaml new file mode 100644 index 00000000..3969d653 --- /dev/null +++ b/examples/cronJob-suport/ks-cronJob-submit.yaml @@ -0,0 +1,40 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: kubescape + labels: + app: kubescape + namespace: kubescape +spec: + # ┌────────────────── timezone (optional) + # | ┌───────────── minute (0 - 59) + # | │ ┌───────────── hour (0 - 23) + # | │ │ ┌───────────── day of the month (1 - 31) + # | │ │ │ ┌───────────── month (1 - 12) + # | │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday; + # | │ │ │ │ │ 7 is also Sunday on some systems) + # | │ │ │ │ │ + # | │ │ │ │ │ + # CRON_TZ=UTC * * * * * + schedule: "0 0 1 * *" + jobTemplate: + spec: + template: + spec: + containers: + - name: kubescape + image: quay.io/armosec/kubescape:latest + imagePullPolicy: IfNotPresent + command: ["/bin/sh","-c"] + args: + - kubescape scan framework nsa --submit + volumeMounts: + - name: kubescape-config-volume + mountPath: /root/.kubescape/config.json + subPath: config.json + restartPolicy: OnFailure + serviceAccountName: kubescape-discovery + volumes: + - name: kubescape-config-volume + configMap: + name: kubescape diff --git a/examples/cronJob-suport/ks-namespace.yaml b/examples/cronJob-suport/ks-namespace.yaml new file mode 100644 index 00000000..54de4704 --- /dev/null +++ b/examples/cronJob-suport/ks-namespace.yaml @@ -0,0 +1,7 @@ +# ------------------- Kubescape User/Customer ID ------------------- # +kind: Namespace +apiVersion: v1 +metadata: + name: kubescape + labels: + app: kubescape diff --git a/examples/cronJob-suport/ks-serviceAccount.yaml b/examples/cronJob-suport/ks-serviceAccount.yaml new file mode 100644 index 00000000..ace8a33a --- /dev/null +++ b/examples/cronJob-suport/ks-serviceAccount.yaml @@ -0,0 +1,61 @@ +--- +# ------------------- Kubescape Service Account ------------------- # +apiVersion: v1 +kind: ServiceAccount +metadata: + labels: + app: kubescape + name: kubescape-discovery + namespace: kubescape + +--- +# ------------------- Kubescape Role & Role Binding ------------------- # +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: kubescape-discovery-role + namespace: kubescape +rules: +- apiGroups: ["*"] + resources: ["*"] + verbs: ["get", "list", "describe"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: kubescape-discovery-binding + namespace: kubescape +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: kubescape-discovery-role +subjects: +- kind: ServiceAccount + name: kubescape-discovery + +--- +# ------------------- Kubescape Cluster Role & Cluster Role Binding ------------------- # +kind: ClusterRole +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: kubescape-discovery-clusterroles + # "namespace" omitted since ClusterRoles are not namespaced +rules: +- apiGroups: ["*"] + resources: ["*"] + verbs: ["get", "list", "describe"] + +--- +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: kubescape-discovery-role-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kubescape-discovery-clusterroles +subjects: +- kind: ServiceAccount + name: kubescape-discovery + namespace: kubescape \ No newline at end of file diff --git a/examples/cronJob-suport/screenshots/account.png b/examples/cronJob-suport/screenshots/account.png new file mode 100644 index 0000000000000000000000000000000000000000..edeebf8728f114b25310b405840d7588d7284be9 GIT binary patch literal 41707 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 zʯ)!}gWLjHRO4n2-K@lgfSp@*c^pvV|W|DW^7q={~S`z3OH>wbDzWwTHtzzI| zEYsaIuKb>HT)y-BPIaX+$`ij$#L=lzC%HvE-})Vj)YUd-N`{9IPelw^J=XvNTNfgp z0*TiX-v?L~Kvr+y%eI3$TL*SjttU$jR{v09*Fe`6oIEpL-mBMpvuNLe^ak4lTFMtL8G5yp+YL~UR+C9^jy)QO^Wg`Z- zxxL_CiD>}T)|;2BPAV~375O5mPx@%w|2#k@W@+^1h53_HkvAvX9=QSER*yUpjG*Ws zf*fL>IN@{pArE;AU=QCybX*~@OQXfUDASBJPrjoBJmD4iawid3C1h{zRhENO!W*a^ zh(F1`-dK5qRFSIdOvtbk)C7H(p-~!$a>)y46RM@r8vSjNC8Y%HTs1VpDKKxYCp~TZ zC?1IVB_`OTW>ZVolJj6PdK_h>e6tsA*&`7%Eg7>9&^er5W7YSGp7bG(8TE299*bei z7J+kq5@H#G!mO=qP}Jn0)U+j0*(dr<5Cqroz_??VVmF`_)QJH8tES^cNAIXHZ)5w4 z-}Jp4_^lNBvH`t^xqwbDhmh&AgClrq3e3OaK9d{e36_Yv!Ze3;zJ1*vhmH5<+0zSJ zlz4DI|LMrg3jM|bP~2C8;l3Q4fF7Ws!F(Yk=gh+g$CjtZ}rZ{Yey zP=;F?YcUCJ-6?!q^20|zio&f`MAI3HpzCh`q*}D6FU2Ee?pZif4TyDZp}m zjfam@0vzGn!et@0m({R(8dxM{W@Zo4bnmcw{PWIsxJ(W%rr_jXLCn{n;sdxQ2FNL}4bUd?fXzo`L~`<0jjCfvh*863wfJwvH^)SV->yea{JuBX z$)$k5DhZJj=W^zDs_Zq2VymYmCQHlo+i_3iy>G;ui?cC>p+&92#IxB}O?}-jc|>AE`B>~m;CVLi-y|5nF;GwDB-;qtoa32%Kb(8LUsxZAmnv*y_38t)%RGN3L>BX?}Cx1!>; z&UWkiB6X`o7pW6JQ2QuEmyk>KO4c*VU$v$Ec2wfIAm$N7fQ+J1qV5hc8mC$v>%DJz zj8JWAtcGoY4?7I3v4^}N9S0&UVKm`s;I`xm=!s}@G4#p zhXgHVToEd9Xd>j}Z$ZpXh#BIHE0RF`N>9uT#SEAk0&S<~l>YR#P4IS9HN@gumw`p1 z$86v(I2GL|u|vEqAABWS#K^%+UbUjt9S9zLRxql;ReDKV?$1X5k$NLaZYD}8%9##a zzBj6jt_j2`_mguG^nNnUZ~c4hJ-#SwFADHn<8_-xbNTX&89h;nN1Qx||4jTBuDZ^Z}&4e%oO%j}t+4=m&# zTmL0d*`}*5#S@G5%Eu2>uUG)HKX>~BLdrLCZ-AVXZMsz$SjZLl@a;T3ijuf+<|Zgp zkh?s(_g^4AHyNI9uKh=XD3FIoA)HWy5{!SO$s9U~f@)tp;eT?+=>q3TV`Gg5$F5BkG2IRXw zu;On8=L?1HY~lF~^B&zuc)!$3$e)?g84kzZQ`Dzq5x^w|CzIg%BA;-(%! zUd}+VAOCg5l>PtMNcO89m*rxqz>=;EtvXzQMw(!hiT$xxfKA0TuFo4`=zN-**a>~% z?BP}(LAmSx=PKD1vAK^5l`R2@yF2m9nO`P#pH2%rG*Uz8e>=B3c3M4HEB94tLaQaP zN%F`1eI|k(&t~9fjtl;RGv00xK4WGv$MGLzC literal 0 HcmV?d00001 diff --git a/examples/cronJob-suport/screenshots/add-cluster.png b/examples/cronJob-suport/screenshots/add-cluster.png new file mode 100644 index 0000000000000000000000000000000000000000..05eb64a6c7dfdc4934bbe6123aaecd6bb4eee69e GIT binary patch literal 122365 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 Q^wQg&|2pEvbon9UPl&ZzI{*Lx literal 0 HcmV?d00001 diff --git a/examples/cronjob/ks-cronjob.yaml b/examples/cronjob/ks-cronjob.yaml index b00b2aff..4151e471 100644 --- a/examples/cronjob/ks-cronjob.yaml +++ b/examples/cronjob/ks-cronjob.yaml @@ -1,3 +1,10 @@ +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +# +# This file is DEPRECATE, please navigate to the official docs -> +# https://github.com/armosec/kubescape/tree/master/examples/cronJob-suport/README.md +# +# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + --- # ------------------- Kubescape Service Account ------------------- # apiVersion: v1 From ffb556a637be63fc1f4f73ac1625ff1d17621c86 Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 28 Oct 2021 09:15:22 +0300 Subject: [PATCH 24/70] update readme --- README.md | 2 +- .../{cronJob-suport => cronJob-support}/README.md | 9 ++++++--- .../ks-configMap.yaml | 0 .../ks-cronJob-non-submit.yaml | 0 .../ks-cronJob-submit.yaml | 0 .../ks-namespace.yaml | 0 .../ks-serviceAccount.yaml | 0 .../screenshots/account.png | Bin .../screenshots/add-cluster.png | Bin examples/cronjob/ks-cronjob.yaml | 2 +- 10 files changed, 8 insertions(+), 5 deletions(-) rename examples/{cronJob-suport => cronJob-support}/README.md (88%) rename examples/{cronJob-suport => cronJob-support}/ks-configMap.yaml (100%) rename examples/{cronJob-suport => cronJob-support}/ks-cronJob-non-submit.yaml (100%) rename examples/{cronJob-suport => cronJob-support}/ks-cronJob-submit.yaml (100%) rename examples/{cronJob-suport => cronJob-support}/ks-namespace.yaml (100%) rename examples/{cronJob-suport => cronJob-support}/ks-serviceAccount.yaml (100%) rename examples/{cronJob-suport => cronJob-support}/screenshots/account.png (100%) rename examples/{cronJob-suport => cronJob-support}/screenshots/add-cluster.png (100%) diff --git a/README.md b/README.md index f0f5f128..d5eb5c4b 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ kubescape scan framework nsa --exceptions examples/exceptions.json ### Repeatedly Kubescape Scanning using a CronJob -For setting up a cronJob please follow the [instructions](examples/cronJob-suport/README.md) +For setting up a cronJob please follow the [instructions](examples/cronJob-support/README.md) ### Helm Support diff --git a/examples/cronJob-suport/README.md b/examples/cronJob-support/README.md similarity index 88% rename from examples/cronJob-suport/README.md rename to examples/cronJob-support/README.md index 5a901ba0..6d4c3d3f 100644 --- a/examples/cronJob-suport/README.md +++ b/examples/cronJob-support/README.md @@ -1,8 +1,11 @@ # Repeatedly Kubescape Scanning -You can scan your cluster repeatedly by adding a cronJob that will repeatedly trigger kubescape +You can scan your cluster repeatedly by adding a `CronJob` that will repeatedly trigger kubescape -## Repeatedly Scanning & Submitting +* Setup [scanning & submitting](#scanning-&-submitting) +* Setup [scanning without submitting](#scanning-without-submitting) + +## Scanning & Submitting If you wish to repeatedly scan and submit the result to the [SaaS UI](https://portal.armo.cloud/) where you can benefit the features the SaaS version provides, please follow this instructions -> @@ -58,7 +61,7 @@ If you wish to repeatedly scan and submit the result to the [SaaS UI](https://po kubectl apply ks-cronJob-submit.yaml ``` -## Repeatedly Scanning Without Submitting +## Scanning Without Submitting If you wish to repeatedly scan but not submit the scan results, follow this instructions -> diff --git a/examples/cronJob-suport/ks-configMap.yaml b/examples/cronJob-support/ks-configMap.yaml similarity index 100% rename from examples/cronJob-suport/ks-configMap.yaml rename to examples/cronJob-support/ks-configMap.yaml diff --git a/examples/cronJob-suport/ks-cronJob-non-submit.yaml b/examples/cronJob-support/ks-cronJob-non-submit.yaml similarity index 100% rename from examples/cronJob-suport/ks-cronJob-non-submit.yaml rename to examples/cronJob-support/ks-cronJob-non-submit.yaml diff --git a/examples/cronJob-suport/ks-cronJob-submit.yaml b/examples/cronJob-support/ks-cronJob-submit.yaml similarity index 100% rename from examples/cronJob-suport/ks-cronJob-submit.yaml rename to examples/cronJob-support/ks-cronJob-submit.yaml diff --git a/examples/cronJob-suport/ks-namespace.yaml b/examples/cronJob-support/ks-namespace.yaml similarity index 100% rename from examples/cronJob-suport/ks-namespace.yaml rename to examples/cronJob-support/ks-namespace.yaml diff --git a/examples/cronJob-suport/ks-serviceAccount.yaml b/examples/cronJob-support/ks-serviceAccount.yaml similarity index 100% rename from examples/cronJob-suport/ks-serviceAccount.yaml rename to examples/cronJob-support/ks-serviceAccount.yaml diff --git a/examples/cronJob-suport/screenshots/account.png b/examples/cronJob-support/screenshots/account.png similarity index 100% rename from examples/cronJob-suport/screenshots/account.png rename to examples/cronJob-support/screenshots/account.png diff --git a/examples/cronJob-suport/screenshots/add-cluster.png b/examples/cronJob-support/screenshots/add-cluster.png similarity index 100% rename from examples/cronJob-suport/screenshots/add-cluster.png rename to examples/cronJob-support/screenshots/add-cluster.png diff --git a/examples/cronjob/ks-cronjob.yaml b/examples/cronjob/ks-cronjob.yaml index 4151e471..4dfc503f 100644 --- a/examples/cronjob/ks-cronjob.yaml +++ b/examples/cronjob/ks-cronjob.yaml @@ -1,7 +1,7 @@ # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! # # This file is DEPRECATE, please navigate to the official docs -> -# https://github.com/armosec/kubescape/tree/master/examples/cronJob-suport/README.md +# https://github.com/armosec/kubescape/tree/master/examples/cronJob-support/README.md # # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! From ca927dec30d407f9faa3adf7c16fbbdf8acbebbe Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 28 Oct 2021 10:05:30 +0300 Subject: [PATCH 25/70] update naming convention --- README.md | 4 ++-- examples/cronJob-support/README.md | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d5eb5c4b..346bd776 100644 --- a/README.md +++ b/README.md @@ -87,13 +87,13 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser ### Examples -* Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/News-Features/Feature-Stories/Article-View/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework and submit results to [ARMO portal](https://portal.armo.cloud/) +* Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/News-Features/Feature-Stories/Article-View/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) ``` kubescape scan framework nsa --submit ``` -* Scan a running Kubernetes cluster with [`MITRE ATT&CK®`](https://www.microsoft.com/security/blog/2021/03/23/secure-containerized-environments-with-updated-threat-matrix-for-kubernetes/) framework and submit results to [ARMO portal](https://portal.armo.cloud/) +* Scan a running Kubernetes cluster with [`MITRE ATT&CK®`](https://www.microsoft.com/security/blog/2021/03/23/secure-containerized-environments-with-updated-threat-matrix-for-kubernetes/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) ``` kubescape scan framework mitre --submit ``` diff --git a/examples/cronJob-support/README.md b/examples/cronJob-support/README.md index 6d4c3d3f..ae21905f 100644 --- a/examples/cronJob-support/README.md +++ b/examples/cronJob-support/README.md @@ -7,7 +7,7 @@ You can scan your cluster repeatedly by adding a `CronJob` that will repeatedly ## Scanning & Submitting -If you wish to repeatedly scan and submit the result to the [SaaS UI](https://portal.armo.cloud/) where you can benefit the features the SaaS version provides, please follow this instructions -> +If you wish to repeatedly scan and submit the result to the [Kubescape SaaS version](https://portal.armo.cloud/) where you can benefit the features the SaaS version provides, please follow this instructions -> 1. Apply kubescape namespace ``` @@ -26,7 +26,7 @@ If you wish to repeatedly scan and submit the result to the [SaaS UI](https://po * Set cluster name: Run `kubectl config current-context` and set the result in the `data.clusterName` field * Set account ID: - 1. Navigate to the [SaaS UI](https://portal.armo.cloud/) and login/sign up for free + 1. Navigate to the [Kubescape SaaS version](https://portal.armo.cloud/) and login/sign up for free 2. Click the `Add Cluster` button on the top right of the page add-cluster 3. Copy the value of `--account` and set it in the `data.customerGUID` field @@ -54,9 +54,9 @@ If you wish to repeatedly scan and submit the result to the [SaaS UI](https://po kubectl apply ks-configMap.yaml ``` -4. Apply cronJob - Before you apply the cronJob, make sure the scanning frequency suites your needs +4. Apply CronJob + Before you apply the cronJob, make sure the scanning frequency suites your needs ``` kubectl apply ks-cronJob-submit.yaml ``` @@ -75,10 +75,9 @@ If you wish to repeatedly scan but not submit the scan results, follow this inst kubectl apply ks-serviceAccount.yaml ``` -3. Apply cronJob +3. Apply CronJob Before you apply the cronJob, make sure the scanning frequency suites your needs - ``` kubectl apply ks-cronJob-non-submit.yaml ``` \ No newline at end of file From 6be24bd22a07588952710a4795890dc42c2f7905 Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 28 Oct 2021 10:21:25 +0300 Subject: [PATCH 26/70] change repeatedly to periodically --- README.md | 2 +- examples/cronJob-support/README.md | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 346bd776..8d6328cb 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --form kubescape scan framework nsa --exceptions examples/exceptions.json ``` -### Repeatedly Kubescape Scanning using a CronJob +### CronJob Scan Periodically For setting up a cronJob please follow the [instructions](examples/cronJob-support/README.md) diff --git a/examples/cronJob-support/README.md b/examples/cronJob-support/README.md index ae21905f..064be710 100644 --- a/examples/cronJob-support/README.md +++ b/examples/cronJob-support/README.md @@ -1,13 +1,13 @@ -# Repeatedly Kubescape Scanning +# Periodically Kubescape Scanning -You can scan your cluster repeatedly by adding a `CronJob` that will repeatedly trigger kubescape +You can scan your cluster periodically by adding a `CronJob` that will repeatedly trigger kubescape -* Setup [scanning & submitting](#scanning-&-submitting) +* Setup [scanning & submitting](#scanning-and-submitting) * Setup [scanning without submitting](#scanning-without-submitting) -## Scanning & Submitting +## Scanning And Submitting -If you wish to repeatedly scan and submit the result to the [Kubescape SaaS version](https://portal.armo.cloud/) where you can benefit the features the SaaS version provides, please follow this instructions -> +If you wish to periodically scan and submit the result to the [Kubescape SaaS version](https://portal.armo.cloud/) where you can benefit the features the SaaS version provides, please follow this instructions -> 1. Apply kubescape namespace ``` @@ -28,8 +28,10 @@ If you wish to repeatedly scan and submit the result to the [Kubescape SaaS vers * Set account ID: 1. Navigate to the [Kubescape SaaS version](https://portal.armo.cloud/) and login/sign up for free 2. Click the `Add Cluster` button on the top right of the page +
add-cluster 3. Copy the value of `--account` and set it in the `data.customerGUID` field +
account Make sure the configMap looks as following; @@ -63,7 +65,7 @@ If you wish to repeatedly scan and submit the result to the [Kubescape SaaS vers ## Scanning Without Submitting -If you wish to repeatedly scan but not submit the scan results, follow this instructions -> +If you wish to periodically scan but not submit the scan results, follow this instructions -> 1. Apply kubescape namespace ``` From 2a45a1a400f4e91682fffa69d92b094f9c392f96 Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 28 Oct 2021 16:29:28 +0300 Subject: [PATCH 27/70] support controls input --- cautils/datastructures.go | 7 ++ cautils/datastructuresmethods.go | 26 +++++ cautils/getter/armoapi.go | 27 +++++ cautils/getter/armoapiutils.go | 16 +++ cautils/getter/getpolicies.go | 6 +- cautils/getter/loadpolicy.go | 26 +++-- cautils/scaninfo.go | 17 +++- opaprocessor/processorhandler.go | 40 ++++---- opaprocessor/processorhandler_test.go | 3 +- policyhandler/handlenotification.go | 29 +----- policyhandler/handlepullpolicies.go | 124 +++++++++-------------- policyhandler/handlepullpoliciesutils.go | 21 ++++ 12 files changed, 205 insertions(+), 137 deletions(-) create mode 100644 cautils/datastructuresmethods.go create mode 100644 policyhandler/handlepullpoliciesutils.go diff --git a/cautils/datastructures.go b/cautils/datastructures.go index 2639f734..f9c92550 100644 --- a/cautils/datastructures.go +++ b/cautils/datastructures.go @@ -13,6 +13,7 @@ type OPASessionObj struct { K8SResources *K8SResources Exceptions []armotypes.PostureExceptionPolicy PostureReport *reporthandling.PostureReport + RegoInputData RegoInputData // map[][] } func NewOPASessionObj(frameworks []reporthandling.Framework, k8sResources *K8SResources) *OPASessionObj { @@ -49,3 +50,9 @@ type Exception struct { Namespaces []string `json:"namespaces"` Regex string `json:"regex"` // not supported } + +type RegoInputData struct { + PostureControlInputs map[string][]string `json:"postureControlInputs"` + // ClusterName string `json:"clusterName"` + // K8sConfig RegoK8sConfig `json:"k8sconfig"` +} diff --git a/cautils/datastructuresmethods.go b/cautils/datastructuresmethods.go new file mode 100644 index 00000000..6cd9941e --- /dev/null +++ b/cautils/datastructuresmethods.go @@ -0,0 +1,26 @@ +package cautils + +import ( + "encoding/json" + + "github.com/open-policy-agent/opa/storage" + "github.com/open-policy-agent/opa/storage/inmem" + "github.com/open-policy-agent/opa/util" +) + +func (data *RegoInputData) SetControlsInputs(controlsInputs map[string][]string) { + data.PostureControlInputs = controlsInputs +} + +func (data *RegoInputData) TOStorage() (storage.Store, error) { + var jsonObj map[string]interface{} + bytesData, err := json.Marshal(*data) + if err != nil { + return nil, err + } + // glog.Infof("RegoDependenciesData: %s", bytesData) + if err := util.UnmarshalJSON(bytesData, &jsonObj); err != nil { + return nil, err + } + return inmem.NewFromObject(jsonObj), nil +} diff --git a/cautils/getter/armoapi.go b/cautils/getter/armoapi.go index 5c4d299d..88f50b2b 100644 --- a/cautils/getter/armoapi.go +++ b/cautils/getter/armoapi.go @@ -140,6 +140,33 @@ func (armoAPI *ArmoAPI) GetCustomerGUID(customerGUID string) (*TenantResponse, e return tenant, nil } +// ControlsInputs // map[][] +func (armoAPI *ArmoAPI) GetAccountConfig(customerGUID, clusterName string) (*armotypes.CustomerConfig, error) { + accountConfig := &armotypes.CustomerConfig{} + if customerGUID == "" { + return accountConfig, nil + } + respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getAccountConfig(customerGUID, clusterName)) + if err != nil { + return nil, err + } + + if err = JSONDecoder(respStr).Decode(&accountConfig); err != nil { + return nil, err + } + + return accountConfig, nil +} + +// ControlsInputs // map[][] +func (armoAPI *ArmoAPI) GetControlsInputs(customerGUID, clusterName string) (map[string][]string, error) { + accountConfig, err := armoAPI.GetAccountConfig(customerGUID, clusterName) + if err == nil { + return accountConfig.Settings.PostureControlInputs, nil + } + return nil, err +} + type TenantResponse struct { TenantID string `json:"tenantId"` Token string `json:"token"` diff --git a/cautils/getter/armoapiutils.go b/cautils/getter/armoapiutils.go index 001a6540..83d43513 100644 --- a/cautils/getter/armoapiutils.go +++ b/cautils/getter/armoapiutils.go @@ -35,6 +35,22 @@ func (armoAPI *ArmoAPI) getExceptionsURL(customerGUID, clusterName string) strin return u.String() } +func (armoAPI *ArmoAPI) getAccountConfig(customerGUID, clusterName string) string { + u := url.URL{} + u.Scheme = "https" + u.Host = armoAPI.apiURL + u.Path = "api/v1/customerConfiguration" + + q := u.Query() + q.Add("customerGUID", customerGUID) + if clusterName != "" { // TODO - fix customer name support in Armo BE + q.Add("clusterName", clusterName) + } + u.RawQuery = q.Encode() + + return u.String() +} + func (armoAPI *ArmoAPI) getCustomerURL() string { u := url.URL{} u.Scheme = "https" diff --git a/cautils/getter/getpolicies.go b/cautils/getter/getpolicies.go index 6d496d78..819efe02 100644 --- a/cautils/getter/getpolicies.go +++ b/cautils/getter/getpolicies.go @@ -7,7 +7,7 @@ import ( type IPolicyGetter interface { GetFramework(name string) (*reporthandling.Framework, error) - GetControl(policyName string) (*reporthandling.Control, error) + GetControl(name string) (*reporthandling.Control, error) } type IExceptionsGetter interface { @@ -16,3 +16,7 @@ type IExceptionsGetter interface { type IBackend interface { GetCustomerGUID(customerGUID string) (*TenantResponse, error) } + +type IControlsInputsGetter interface { + GetControlsInputs(customerGUID, clusterName string) (map[string][]string, error) +} diff --git a/cautils/getter/loadpolicy.go b/cautils/getter/loadpolicy.go index e71451d5..dafc2201 100644 --- a/cautils/getter/loadpolicy.go +++ b/cautils/getter/loadpolicy.go @@ -30,7 +30,7 @@ func NewLoadPolicy(filePaths []string) *LoadPolicy { func (lp *LoadPolicy) GetControl(controlName string) (*reporthandling.Control, error) { control := &reporthandling.Control{} - filePath := lp.getFileForControl() + filePath := lp.filePath() f, err := os.ReadFile(filePath) if err != nil { return nil, err @@ -79,7 +79,7 @@ func (lp *LoadPolicy) GetFramework(frameworkName string) (*reporthandling.Framew } func (lp *LoadPolicy) GetExceptions(customerGUID, clusterName string) ([]armotypes.PostureExceptionPolicy, error) { - filePath := lp.getFileForException() + filePath := lp.filePath() exception := []armotypes.PostureExceptionPolicy{} f, err := os.ReadFile(filePath) if err != nil { @@ -90,10 +90,24 @@ func (lp *LoadPolicy) GetExceptions(customerGUID, clusterName string) ([]armotyp return exception, err } -func (lp *LoadPolicy) getFileForException() string { - return lp.filePaths[0] +func (lp *LoadPolicy) GetControlsInputs(customerGUID, clusterName string) (map[string][]string, error) { + filePath := lp.filePath() + accountConfig := &armotypes.CustomerConfig{} + f, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + if err = json.Unmarshal(f, &accountConfig); err == nil { + return accountConfig.Settings.PostureControlInputs, nil + } + return nil, err } -func (lp *LoadPolicy) getFileForControl() string { - return lp.filePaths[0] +// temporary support for a list of files +func (lp *LoadPolicy) filePath() string { + if len(lp.filePaths) > 0 { + return lp.filePaths[0] + } + return "" } diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index ca0b064d..aa4a0ad0 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -10,7 +10,8 @@ import ( type ScanInfo struct { Getters PolicyIdentifier []reporthandling.PolicyIdentifier - UseExceptions string // Load exceptions configuration + UseExceptions string // Load file with exceptions configuration + ControlsInputs string // Load file with inputs for controls UseFrom []string // Load framework from local file (instead of download). Use when running offline UseDefault bool // Load framework from cached file (instead of download). Use when running offline Format string // Format results (table, json, junit ...) @@ -26,13 +27,15 @@ type ScanInfo struct { } type Getters struct { - ExceptionsGetter getter.IExceptionsGetter - PolicyGetter getter.IPolicyGetter + ExceptionsGetter getter.IExceptionsGetter + ControlsInputsGetter getter.IControlsInputsGetter + PolicyGetter getter.IPolicyGetter } func (scanInfo *ScanInfo) Init() { scanInfo.setUseFrom() scanInfo.setUseExceptions() + scanInfo.setAccountConfig() scanInfo.setOutputFile() scanInfo.setGetter() @@ -45,7 +48,15 @@ func (scanInfo *ScanInfo) setUseExceptions() { } else { scanInfo.ExceptionsGetter = getter.GetArmoAPIConnector() } +} +func (scanInfo *ScanInfo) setAccountConfig() { + if scanInfo.ControlsInputs != "" { + // load account config from file + scanInfo.ControlsInputsGetter = getter.NewLoadPolicy([]string{scanInfo.ControlsInputs}) + } else { + scanInfo.ControlsInputsGetter = getter.GetArmoAPIConnector() + } } func (scanInfo *ScanInfo) setUseFrom() { if scanInfo.UseDefault { diff --git a/opaprocessor/processorhandler.go b/opaprocessor/processorhandler.go index d4a6b759..5e17d37b 100644 --- a/opaprocessor/processorhandler.go +++ b/opaprocessor/processorhandler.go @@ -15,42 +15,37 @@ import ( "github.com/golang/glog" "github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/rego" - "github.com/open-policy-agent/opa/storage" uuid "github.com/satori/go.uuid" ) const ScoreConfigPath = "/resources/config" -var RegoK8sCredentials storage.Store - type OPAProcessorHandler struct { - processedPolicy *chan *cautils.OPASessionObj - reportResults *chan *cautils.OPASessionObj - // componentConfig cautils.ComponentConfig + processedPolicy *chan *cautils.OPASessionObj + reportResults *chan *cautils.OPASessionObj + regoDependenciesData *resources.RegoDependenciesData } type OPAProcessor struct { *cautils.OPASessionObj + regoDependenciesData *resources.RegoDependenciesData } -func NewOPAProcessor(sessionObj *cautils.OPASessionObj) *OPAProcessor { +func NewOPAProcessor(sessionObj *cautils.OPASessionObj, regoDependenciesData *resources.RegoDependenciesData) *OPAProcessor { + if regoDependenciesData != nil && sessionObj != nil { + regoDependenciesData.PostureControlInputs = sessionObj.RegoInputData.PostureControlInputs + } return &OPAProcessor{ - OPASessionObj: sessionObj, + OPASessionObj: sessionObj, + regoDependenciesData: regoDependenciesData, } } func NewOPAProcessorHandler(processedPolicy, reportResults *chan *cautils.OPASessionObj) *OPAProcessorHandler { - - regoDependenciesData := resources.NewRegoDependenciesData(k8sinterface.GetK8sConfig(), cautils.ClusterName) - store, err := regoDependenciesData.TOStorage() - if err != nil { - panic(err) - } - RegoK8sCredentials = store - return &OPAProcessorHandler{ - processedPolicy: processedPolicy, - reportResults: reportResults, + processedPolicy: processedPolicy, + reportResults: reportResults, + regoDependenciesData: resources.NewRegoDependenciesData(k8sinterface.GetK8sConfig(), cautils.ClusterName), } } @@ -58,7 +53,7 @@ func (opaHandler *OPAProcessorHandler) ProcessRulesListenner() { for { opaSessionObj := <-*opaHandler.processedPolicy - opap := NewOPAProcessor(opaSessionObj) + opap := NewOPAProcessor(opaSessionObj, opaHandler.regoDependenciesData) // process if err := opap.Process(); err != nil { @@ -203,11 +198,16 @@ func (opap *OPAProcessor) runRegoOnK8s(rule *reporthandling.PolicyRule, k8sObjec } func (opap *OPAProcessor) regoEval(inputObj []map[string]interface{}, compiledRego *ast.Compiler) ([]reporthandling.RuleResponse, error) { + store, err := opap.regoDependenciesData.TOStorage() // get store + if err != nil { + return nil, err + } + rego := rego.New( rego.Query("data.armo_builtins"), // get package name from rule rego.Compiler(compiledRego), rego.Input(inputObj), - rego.Store(RegoK8sCredentials), + rego.Store(store), ) // Run evaluation diff --git a/opaprocessor/processorhandler_test.go b/opaprocessor/processorhandler_test.go index 49e0b4f2..08e11154 100644 --- a/opaprocessor/processorhandler_test.go +++ b/opaprocessor/processorhandler_test.go @@ -5,6 +5,7 @@ import ( "github.com/armosec/kubescape/cautils" "github.com/armosec/opa-utils/reporthandling" + "github.com/armosec/opa-utils/resources" "github.com/armosec/k8s-interface/k8sinterface" // _ "k8s.io/client-go/plugin/pkg/client/auth" @@ -24,7 +25,7 @@ func TestProcess(t *testing.T) { opaSessionObj.Frameworks = []reporthandling.Framework{*reporthandling.MockFrameworkA()} opaSessionObj.K8SResources = &k8sResources - opap := NewOPAProcessor(opaSessionObj) + opap := NewOPAProcessor(opaSessionObj, resources.NewRegoDependenciesDataMock()) opap.Process() opap.updateResults() for _, f := range opap.PostureReport.FrameworkReports { diff --git a/policyhandler/handlenotification.go b/policyhandler/handlenotification.go index 3a3cfdd5..8db8963e 100644 --- a/policyhandler/handlenotification.go +++ b/policyhandler/handlenotification.go @@ -6,8 +6,6 @@ import ( "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/resourcehandler" "github.com/armosec/opa-utils/reporthandling" - - "github.com/armosec/armoapi-go/armotypes" ) // PolicyHandler - @@ -33,15 +31,9 @@ func (policyHandler *PolicyHandler) HandleNotificationRequest(notification *repo policyHandler.getters = &scanInfo.Getters // get policies - frameworks, exceptions, err := policyHandler.getPolicies(notification) - if err != nil { + if err := policyHandler.getPolicies(notification, opaSessionObj); err != nil { return err } - if len(frameworks) == 0 { - return fmt.Errorf("empty list of frameworks") - } - opaSessionObj.Frameworks = frameworks - opaSessionObj.Exceptions = exceptions k8sResources, err := policyHandler.getResources(notification, opaSessionObj, scanInfo) if err != nil { @@ -57,25 +49,6 @@ func (policyHandler *PolicyHandler) HandleNotificationRequest(notification *repo return nil } -func (policyHandler *PolicyHandler) getPolicies(notification *reporthandling.PolicyNotification) ([]reporthandling.Framework, []armotypes.PostureExceptionPolicy, error) { - - cautils.ProgressTextDisplay("Downloading/Loading policy definitions") - - frameworks, exceptions, err := policyHandler.GetPoliciesFromBackend(notification) - if err != nil { - return frameworks, exceptions, err - } - - if len(frameworks) == 0 { - err := fmt.Errorf("could not download any policies, please check previous logs") - return frameworks, exceptions, err - } - //if notification.Rules - cautils.SuccessTextDisplay("Downloaded/Loaded policy") - - return frameworks, exceptions, nil -} - func (policyHandler *PolicyHandler) getResources(notification *reporthandling.PolicyNotification, opaSessionObj *cautils.OPASessionObj, scanInfo *cautils.ScanInfo) (*cautils.K8SResources, error) { opaSessionObj.PostureReport.ClusterAPIServerInfo = policyHandler.resourceHandler.GetClusterAPIServerInfo() diff --git a/policyhandler/handlepullpolicies.go b/policyhandler/handlepullpolicies.go index 579c05db..44f895ac 100644 --- a/policyhandler/handlepullpolicies.go +++ b/policyhandler/handlepullpolicies.go @@ -2,103 +2,71 @@ package policyhandler import ( "fmt" - "strings" - "github.com/armosec/armoapi-go/armotypes" "github.com/armosec/kubescape/cautils" "github.com/armosec/opa-utils/reporthandling" ) -func (policyHandler *PolicyHandler) GetPoliciesFromBackend(notification *reporthandling.PolicyNotification) ([]reporthandling.Framework, []armotypes.PostureExceptionPolicy, error) { - var errs error - frameworks := []reporthandling.Framework{} - exceptionPolicies := []armotypes.PostureExceptionPolicy{} - // Get - cacli opa get - rule := GetScanKind(notification) +func (policyHandler *PolicyHandler) getPolicies(notification *reporthandling.PolicyNotification, policiesAndResources *cautils.OPASessionObj) error { + cautils.ProgressTextDisplay("Downloading/Loading policy definitions") - switch rule.Kind { - case reporthandling.KindFramework: + frameworks, err := policyHandler.getScanPolicies(notification) + if err != nil { + return err + } + if len(frameworks) == 0 { + return fmt.Errorf("failed to download policies, please ARMO team for more information") + } + + policiesAndResources.Frameworks = frameworks + + // get exceptions + exceptionPolicies, err := policyHandler.getters.ExceptionsGetter.GetExceptions(cautils.CustomerGUID, cautils.ClusterName) + if err == nil { + policiesAndResources.Exceptions = exceptionPolicies + } + + // get account configuration + controlsInputs, err := policyHandler.getters.ControlsInputsGetter.GetControlsInputs(cautils.CustomerGUID, cautils.ClusterName) + if err == nil { + policiesAndResources.RegoInputData.PostureControlInputs = controlsInputs + } + + cautils.SuccessTextDisplay("Downloaded/Loaded policy") + return nil +} + +func (policyHandler *PolicyHandler) getScanPolicies(notification *reporthandling.PolicyNotification) ([]reporthandling.Framework, error) { + frameworks := []reporthandling.Framework{} + + switch getScanKind(notification) { + case reporthandling.KindFramework: // Download frameworks for _, rule := range notification.Rules { - receivedFramework, recExceptionPolicies, err := policyHandler.getFrameworkPolicies(rule.Name) + receivedFramework, err := policyHandler.getters.PolicyGetter.GetFramework(rule.Name) + if err != nil { + return frameworks, policyDownloadError(err) + } if receivedFramework != nil { frameworks = append(frameworks, *receivedFramework) - if recExceptionPolicies != nil { - exceptionPolicies = append(exceptionPolicies, recExceptionPolicies...) - } - } else if err != nil { - if strings.Contains(err.Error(), "unsupported protocol scheme") { - err = fmt.Errorf("failed to download from GitHub release, try running with `--use-default` flag") - } - return nil, nil, fmt.Errorf("kind: %v, name: %s, error: %s", rule.Kind, rule.Name, err.Error()) } } - case reporthandling.KindControl: + case reporthandling.KindControl: // Download controls f := reporthandling.Framework{} var receivedControl *reporthandling.Control - var recExceptionPolicies []armotypes.PostureExceptionPolicy var err error for _, rule := range notification.Rules { - receivedControl, recExceptionPolicies, err = policyHandler.getControl(rule.Name) - if receivedControl != nil { - f.Controls = append(f.Controls, *receivedControl) - if recExceptionPolicies != nil { - exceptionPolicies = append(exceptionPolicies, recExceptionPolicies...) - } - - } else if err != nil { - if strings.Contains(err.Error(), "unsupported protocol scheme") { - err = fmt.Errorf("failed to download from GitHub release, try running with `--use-default` flag") - } - return nil, nil, fmt.Errorf("error: %s", err.Error()) + receivedControl, err = policyHandler.getters.PolicyGetter.GetControl(rule.Name) + if err != nil { + return frameworks, policyDownloadError(err) } } + if receivedControl != nil { + f.Controls = append(f.Controls, *receivedControl) + } frameworks = append(frameworks, f) // TODO: add case for control from file default: - err := fmt.Errorf("missing rule kind, expected: %s", reporthandling.KindFramework) - errs = fmt.Errorf("%s", err.Error()) + return frameworks, fmt.Errorf("unknown policy kind") } - return frameworks, exceptionPolicies, errs -} - -func (policyHandler *PolicyHandler) getFrameworkPolicies(policyName string) (*reporthandling.Framework, []armotypes.PostureExceptionPolicy, error) { - receivedFramework, err := policyHandler.getters.PolicyGetter.GetFramework(policyName) - if err != nil { - return nil, nil, err - } - - receivedException, err := policyHandler.getters.ExceptionsGetter.GetExceptions(cautils.CustomerGUID, cautils.ClusterName) - if err != nil { - return receivedFramework, nil, err - } - - return receivedFramework, receivedException, nil -} - -func GetScanKind(notification *reporthandling.PolicyNotification) *reporthandling.PolicyIdentifier { - if len(notification.Rules) > 0 { - return ¬ification.Rules[0] - } - return nil -} - -// Get control by name -func (policyHandler *PolicyHandler) getControl(policyName string) (*reporthandling.Control, []armotypes.PostureExceptionPolicy, error) { - - control := &reporthandling.Control{} - var err error - control, err = policyHandler.getters.PolicyGetter.GetControl(policyName) - if err != nil { - return control, nil, err - } - // if control == nil { - // return control, nil, fmt.Errorf("control not found") - // } - - exceptions, err := policyHandler.getters.ExceptionsGetter.GetExceptions(cautils.CustomerGUID, cautils.ClusterName) - if err != nil { - return control, nil, err - } - - return control, exceptions, nil + return frameworks, nil } diff --git a/policyhandler/handlepullpoliciesutils.go b/policyhandler/handlepullpoliciesutils.go new file mode 100644 index 00000000..c4a4b321 --- /dev/null +++ b/policyhandler/handlepullpoliciesutils.go @@ -0,0 +1,21 @@ +package policyhandler + +import ( + "fmt" + "strings" + + "github.com/armosec/opa-utils/reporthandling" +) + +func getScanKind(notification *reporthandling.PolicyNotification) reporthandling.NotificationPolicyKind { + if len(notification.Rules) > 0 { + return notification.Rules[0].Kind + } + return "unknown" +} +func policyDownloadError(err error) error { + if strings.Contains(err.Error(), "unsupported protocol scheme") { + err = fmt.Errorf("failed to download from GitHub release, try running with `--use-default` flag") + } + return err +} From 7b061a4e51e10f2e58be40224bee5e5374c8ad4f Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 31 Oct 2021 14:38:13 +0200 Subject: [PATCH 28/70] update opa pkg --- clihandler/cmd/scan.go | 3 ++- go.mod | 3 ++- go.sum | 8 +++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index f9c2e65b..7f77ce05 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -42,5 +42,6 @@ func init() { scanCmd.PersistentFlags().Uint16VarP(&scanInfo.FailThreshold, "fail-threshold", "t", 0, "Failure threshold is the percent bellow which the command fails and returns exit code 1") scanCmd.PersistentFlags().StringSliceVar(&scanInfo.UseFrom, "use-from", nil, "Load local policy object from specified path. If not used will download latest") scanCmd.PersistentFlags().BoolVar(&scanInfo.UseDefault, "use-default", false, "Load local policy object from default path. If not used will download latest") - scanCmd.PersistentFlags().StringVar(&scanInfo.UseExceptions, "exceptions", "", "Path to an exceptions obj. If not set will download exceptions from Armo management portal") + scanCmd.PersistentFlags().StringVar(&scanInfo.UseExceptions, "exceptions", "", "Path to an exceptions obj. If not set will download exceptions from ARMO management portal") + scanCmd.PersistentFlags().StringVar(&scanInfo.ControlsInputs, "controls-config", "", "Path to an controls-config obj. If not set will download controls-config from ARMO management portal") } diff --git a/go.mod b/go.mod index 508431cb..95cbcbd8 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.17 require ( github.com/armosec/armoapi-go v0.0.8 github.com/armosec/k8s-interface v0.0.8 - github.com/armosec/opa-utils v0.0.18 + github.com/armosec/opa-utils v0.0.21 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 github.com/enescakir/emoji v1.0.0 @@ -32,6 +32,7 @@ require ( github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/armosec/rbac-utils v0.0.1 // indirect github.com/armosec/utils-k8s-go v0.0.1 // indirect github.com/coreos/go-oidc v2.2.1+incompatible // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index 2ef3ea74..f46de3bb 100644 --- a/go.sum +++ b/go.sum @@ -87,11 +87,12 @@ github.com/armosec/armoapi-go v0.0.2/go.mod h1:vIK17yoKbJRQyZXWWLe3AqfqCRITxW8qm github.com/armosec/armoapi-go v0.0.7/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= github.com/armosec/armoapi-go v0.0.8 h1:JPa9rZynuE2RucamDh6dsy/sjCScmWDsyt1zagJFCDo= github.com/armosec/armoapi-go v0.0.8/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= -github.com/armosec/k8s-interface v0.0.5/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= github.com/armosec/k8s-interface v0.0.8 h1:Eo3Qen4yFXxzVem49FNeij2ckyzHSAJ0w6PZMaSEIm8= github.com/armosec/k8s-interface v0.0.8/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= -github.com/armosec/opa-utils v0.0.18 h1:1hL5v2KCD8yStuwzul+gq1zg9+RCV9N3kHoRepKnrg0= -github.com/armosec/opa-utils v0.0.18/go.mod h1:E0mFTVx+4BYAVvO2hxWnIniv/IZIogRCak8BkKd7KK4= +github.com/armosec/opa-utils v0.0.21 h1:LzQ4LoY+fKQIhyLdR7cI/YfjdCztLdAeP40Ct3Wcw5o= +github.com/armosec/opa-utils v0.0.21/go.mod h1:JaE2a0kB2O22JZCqBBfOS9Pvh9rXs9xXXb9lVo01rTs= +github.com/armosec/rbac-utils v0.0.1 h1:N2MI98F/0zbDjmRZ29CNElU1AXkFLk5csd/qAHOBdXY= +github.com/armosec/rbac-utils v0.0.1/go.mod h1:pQ8CBiij8kSKV7aeZm9FMvtZN28VgA7LZcYyTWimq40= github.com/armosec/utils-go v0.0.2/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= github.com/armosec/utils-go v0.0.3 h1:uyQI676yRciQM0sSN9uPoqHkbspTxHO0kmzXhBeE/xU= github.com/armosec/utils-go v0.0.3/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= @@ -99,6 +100,7 @@ github.com/armosec/utils-k8s-go v0.0.1 h1:Ay3y7fW+4+FjVc0+obOWm8YsnEvM31vPAVoKTy github.com/armosec/utils-k8s-go v0.0.1/go.mod h1:qrU4pmY2iZsOb39Eltpm0sTTNM3E4pmeyWx4dgDUC2U= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/aws/aws-sdk-go v1.41.1/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= +github.com/aws/aws-sdk-go v1.41.11/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= From 959b25e8b7e1c971995479eeaaf43f832df6eb71 Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 31 Oct 2021 15:05:22 +0200 Subject: [PATCH 29/70] adding smoke tests --- .github/workflows/build.yaml | 5 +++++ .github/workflows/build_dev.yaml | 6 ++++++ build.py | 13 ++----------- smoke_testing/init.py | 15 +++++++++++++++ smoke_testing/smoke_utils.py | 30 ++++++++++++++++++++++++++++++ smoke_testing/test_command.py | 31 +++++++++++++++++++++++++++++++ smoke_testing/test_version.py | 27 +++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 smoke_testing/init.py create mode 100644 smoke_testing/smoke_utils.py create mode 100644 smoke_testing/test_command.py create mode 100644 smoke_testing/test_version.py diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 081f3939..ccc042e2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -46,6 +46,11 @@ jobs: CGO_ENABLED: 0 run: python3 --version && python3 build.py + - name: Smoke Testing + env: + RELEASE: v1.0.${{ github.run_number }} + run: python3 smoke_testing/init.py + - name: Upload Release binaries id: upload-release-asset uses: actions/upload-release-asset@v1 diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yaml index 9bbd45a5..8f5f552d 100644 --- a/.github/workflows/build_dev.yaml +++ b/.github/workflows/build_dev.yaml @@ -30,6 +30,12 @@ jobs: CGO_ENABLED: 0 run: python3 --version && python3 build.py + - name: Smoke Testing + env: + RELEASE: v1.0.${{ github.run_number }} + KUBESCAPE_SKIP_UPDATE_CHECK: "true" + run: python3 smoke_testing/init.py + - name: Upload build artifacts uses: actions/upload-artifact@v2 with: diff --git a/build.py b/build.py index 2104775d..685604bb 100644 --- a/build.py +++ b/build.py @@ -60,9 +60,6 @@ def main(): status = subprocess.call(["go", "build", "-o", "%s/%s" % (buildDir, packageName), "-ldflags" ,ldflags]) checkStatus(status, "Failed to build kubescape") - test_cli_prints(buildDir,packageName) - - sha1 = hashlib.sha1() with open(buildDir + "/" + packageName, "rb") as kube: sha1.update(kube.read()) @@ -70,13 +67,7 @@ def main(): kube_sha.write(sha1.hexdigest()) print("Build Done") - -def test_cli_prints(buildDir,packageName): - bin_cli = os.path.abspath(os.path.join(buildDir,packageName)) - - print(f"testing CLI prints on {bin_cli}") - status = str(subprocess.check_output([bin_cli, "-h"])) - assert "download" in status, "download is missing: " + status - + + if __name__ == "__main__": main() diff --git a/smoke_testing/init.py b/smoke_testing/init.py new file mode 100644 index 00000000..e1a5e1f5 --- /dev/null +++ b/smoke_testing/init.py @@ -0,0 +1,15 @@ + +tests_pkg = [ + "test_command" + , "test_version" +] + + +def run(): + for i in tests_pkg: + m = __import__(i) + m.run() + + +if __name__ == "__main__": + run() diff --git a/smoke_testing/smoke_utils.py b/smoke_testing/smoke_utils.py new file mode 100644 index 00000000..93b9eeac --- /dev/null +++ b/smoke_testing/smoke_utils.py @@ -0,0 +1,30 @@ +import platform +from os import path +from sys import stderr + + +def get_build_dir(): + current_platform = platform.system() + build_dir = "build/" + + if current_platform == "Windows": build_dir += "windows-latest" + elif current_platform == "Linux": build_dir += "ubuntu-latest" + elif current_platform == "Darwin": build_dir += "macos-latest" + else: raise OSError(f"Platform {current_platform} is not supported!") + + return build_dir + + +def get_package_name(): + return "kubescape" + + +def get_bin_cli(): + return path.abspath(path.join(get_build_dir(), get_package_name())) + + +def check_status(status, msg): + if status != 0: + stderr.write(msg) + exit(status) + diff --git a/smoke_testing/test_command.py b/smoke_testing/test_command.py new file mode 100644 index 00000000..52e0d48d --- /dev/null +++ b/smoke_testing/test_command.py @@ -0,0 +1,31 @@ +import subprocess +import smoke_utils + + +def test_command(command: list): + print(f"Testing \"{' '.join(command[1:])}\" command") + + msg = str(subprocess.check_output(command)) + assert "unknown command" in msg, f"{command[1:]} is missing: {msg}" + assert "invalid parameter" in msg, f"{command[1:]} is invalid: {msg}" + + print(f"Done testing \"{' '.join(command[1:])}\" command") + + +def run(): + print("Testing supported commands") + + bin_cli = smoke_utils.get_bin_cli() + test_command(command=[bin_cli, "version"]) + test_command(command=[bin_cli, "download"]) + test_command(command=[bin_cli, "config"]) + test_command(command=[bin_cli, "help"]) + test_command(command=[bin_cli, "scan"]) + test_command(command=[bin_cli, "scan", "framework"]) + test_command(command=[bin_cli, "scan", "control"]) + + print("Done testing commands") + + +if __name__ == "__main__": + run() diff --git a/smoke_testing/test_version.py b/smoke_testing/test_version.py new file mode 100644 index 00000000..96ebdf30 --- /dev/null +++ b/smoke_testing/test_version.py @@ -0,0 +1,27 @@ +import os +import subprocess +import smoke_utils + + +def test_command(command: list): + print(f"Testing \"{' '.join(command[1:])}\" command") + + msg = str(subprocess.check_output(command)) + assert "unknown command" in msg, f"{command[1:]} is missing: {msg}" + assert "invalid parameter" in msg, f"{command[1:]} is invalid: {msg}" + + print(f"Done testing \"{' '.join(command[1:])}\" command") + + +def run(): + print("Testing version") + + ver = os.getenv("RELEASE") + msg = str(subprocess.check_output([smoke_utils.get_bin_cli(), "version"])) + assert ver in msg, f"expected version: {ver}, found: {msg}" + + print("Done testing version") + + +if __name__ == "__main__": + run() From 17aec665cfd7d3591f70aa5d028dfe25a0028921 Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 31 Oct 2021 15:31:23 +0200 Subject: [PATCH 30/70] updated tests --- .github/workflows/build.yaml | 1 + smoke_testing/test_command.py | 4 ++-- smoke_testing/test_version.py | 10 ---------- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ccc042e2..bf538710 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -49,6 +49,7 @@ jobs: - name: Smoke Testing env: RELEASE: v1.0.${{ github.run_number }} + KUBESCAPE_SKIP_UPDATE_CHECK: "true" run: python3 smoke_testing/init.py - name: Upload Release binaries diff --git a/smoke_testing/test_command.py b/smoke_testing/test_command.py index 52e0d48d..1bd7b587 100644 --- a/smoke_testing/test_command.py +++ b/smoke_testing/test_command.py @@ -6,8 +6,8 @@ def test_command(command: list): print(f"Testing \"{' '.join(command[1:])}\" command") msg = str(subprocess.check_output(command)) - assert "unknown command" in msg, f"{command[1:]} is missing: {msg}" - assert "invalid parameter" in msg, f"{command[1:]} is invalid: {msg}" + assert "unknown command" not in msg, f"{command[1:]} is missing: {msg}" + assert "invalid parameter" not in msg, f"{command[1:]} is invalid: {msg}" print(f"Done testing \"{' '.join(command[1:])}\" command") diff --git a/smoke_testing/test_version.py b/smoke_testing/test_version.py index 96ebdf30..739e3784 100644 --- a/smoke_testing/test_version.py +++ b/smoke_testing/test_version.py @@ -3,16 +3,6 @@ import subprocess import smoke_utils -def test_command(command: list): - print(f"Testing \"{' '.join(command[1:])}\" command") - - msg = str(subprocess.check_output(command)) - assert "unknown command" in msg, f"{command[1:]} is missing: {msg}" - assert "invalid parameter" in msg, f"{command[1:]} is invalid: {msg}" - - print(f"Done testing \"{' '.join(command[1:])}\" command") - - def run(): print("Testing version") From 294f886588371271a2f7b81f7dca2216d603e357 Mon Sep 17 00:00:00 2001 From: Yonah Dissen Date: Sun, 31 Oct 2021 17:50:55 +0200 Subject: [PATCH 31/70] fix version for cli in docker image --- .github/workflows/build.yaml | 4 ++-- .github/workflows/build_dev.yaml | 2 +- build/Dockerfile | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 081f3939..5cee1d15 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -71,8 +71,8 @@ jobs: run: echo quay.io/armosec/kubescape:v1.0.${{ github.run_number }} > build_tag.txt - name: Build the Docker image - run: docker build . --file build/Dockerfile --tag $(cat build_tag.txt) - + run: docker build . --file build/Dockerfile --tag $(cat build_tag.txt) --build-arg run_number=${{ github.run_number }} + - name: Re-Tag Image to latest run: docker tag $(cat build_tag.txt) quay.io/armosec/kubescape:latest diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yaml index 9bbd45a5..05aa6a27 100644 --- a/.github/workflows/build_dev.yaml +++ b/.github/workflows/build_dev.yaml @@ -50,7 +50,7 @@ jobs: run: echo quay.io/armosec/kubescape:dev-v1.0.${{ github.run_number }} > build_tag.txt - name: Build the Docker image - run: docker build . --file build/Dockerfile --tag $(cat build_tag.txt) + run: docker build . --file build/Dockerfile --tag $(cat build_tag.txt) --build-arg run_number=${{ github.run_number }} - name: Login to Quay.io env: # Or as an environment variable diff --git a/build/Dockerfile b/build/Dockerfile index f80803cb..9d5545dc 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,5 +1,7 @@ FROM golang:1.17-alpine as builder #ENV GOPROXY=https://goproxy.io,direct +ARG run_number +ENV RELEASE=v1.0.${run_number} ENV GO111MODULE= ENV CGO_ENABLED=0 From 5a90dc46f01e795a2fb0433702c41650119d60ec Mon Sep 17 00:00:00 2001 From: Yonah Dissen Date: Mon, 1 Nov 2021 09:17:46 +0200 Subject: [PATCH 32/70] fix version for cli in docker image --- build/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build/Dockerfile b/build/Dockerfile index 9d5545dc..55e168c5 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,7 +1,10 @@ FROM golang:1.17-alpine as builder #ENV GOPROXY=https://goproxy.io,direct + ARG run_number + ENV RELEASE=v1.0.${run_number} + ENV GO111MODULE= ENV CGO_ENABLED=0 From ae7810f0d39c6e8c5cb7c0883818a6de7c550c46 Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 1 Nov 2021 11:44:07 +0200 Subject: [PATCH 33/70] support input from file --- .github/workflows/build.yaml | 2 +- .github/workflows/build_dev.yaml | 2 +- .gitignore | 1 + smoke_testing/init.py | 9 ++++++--- smoke_testing/smoke_utils.py | 4 ++++ smoke_testing/test_command.py | 20 ++++++++++---------- smoke_testing/test_version.py | 7 ++++--- 7 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index bf538710..6b3b76dc 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -50,7 +50,7 @@ jobs: env: RELEASE: v1.0.${{ github.run_number }} KUBESCAPE_SKIP_UPDATE_CHECK: "true" - run: python3 smoke_testing/init.py + run: python3 smoke_testing/init.py ${PWD}/build/${{ matrix.os }}/kubescape - name: Upload Release binaries id: upload-release-asset diff --git a/.github/workflows/build_dev.yaml b/.github/workflows/build_dev.yaml index 8f5f552d..eff77b8b 100644 --- a/.github/workflows/build_dev.yaml +++ b/.github/workflows/build_dev.yaml @@ -34,7 +34,7 @@ jobs: env: RELEASE: v1.0.${{ github.run_number }} KUBESCAPE_SKIP_UPDATE_CHECK: "true" - run: python3 smoke_testing/init.py + run: python3 smoke_testing/init.py ${PWD}/build/${{ matrix.os }}/kubescape - name: Upload build artifacts uses: actions/upload-artifact@v2 diff --git a/.gitignore b/.gitignore index bf33394d..3c3ed147 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ *kubescape* *debug* *vender* +*.pyc* .idea \ No newline at end of file diff --git a/smoke_testing/init.py b/smoke_testing/init.py index e1a5e1f5..3f88c756 100644 --- a/smoke_testing/init.py +++ b/smoke_testing/init.py @@ -1,3 +1,6 @@ +import sys +import smoke_utils + tests_pkg = [ "test_command" @@ -5,11 +8,11 @@ tests_pkg = [ ] -def run(): +def run(**kwargs): for i in tests_pkg: m = __import__(i) - m.run() + m.run(**kwargs) if __name__ == "__main__": - run() + run(kubescape_exec=smoke_utils.get_exec_from_args(sys.argv)) diff --git a/smoke_testing/smoke_utils.py b/smoke_testing/smoke_utils.py index 93b9eeac..528193a7 100644 --- a/smoke_testing/smoke_utils.py +++ b/smoke_testing/smoke_utils.py @@ -28,3 +28,7 @@ def check_status(status, msg): stderr.write(msg) exit(status) + +def get_exec_from_args(args: list): + return args[1] + diff --git a/smoke_testing/test_command.py b/smoke_testing/test_command.py index 1bd7b587..43277e5f 100644 --- a/smoke_testing/test_command.py +++ b/smoke_testing/test_command.py @@ -1,5 +1,6 @@ import subprocess import smoke_utils +import sys def test_command(command: list): @@ -12,20 +13,19 @@ def test_command(command: list): print(f"Done testing \"{' '.join(command[1:])}\" command") -def run(): +def run(kubescape_exec:str): print("Testing supported commands") - bin_cli = smoke_utils.get_bin_cli() - test_command(command=[bin_cli, "version"]) - test_command(command=[bin_cli, "download"]) - test_command(command=[bin_cli, "config"]) - test_command(command=[bin_cli, "help"]) - test_command(command=[bin_cli, "scan"]) - test_command(command=[bin_cli, "scan", "framework"]) - test_command(command=[bin_cli, "scan", "control"]) + test_command(command=[kubescape_exec, "version"]) + test_command(command=[kubescape_exec, "download"]) + test_command(command=[kubescape_exec, "config"]) + test_command(command=[kubescape_exec, "help"]) + test_command(command=[kubescape_exec, "scan"]) + test_command(command=[kubescape_exec, "scan", "framework"]) + test_command(command=[kubescape_exec, "scan", "control"]) print("Done testing commands") if __name__ == "__main__": - run() + run(kubescape_exec=smoke_utils.get_exec_from_args(sys.argv)) diff --git a/smoke_testing/test_version.py b/smoke_testing/test_version.py index 739e3784..689e3934 100644 --- a/smoke_testing/test_version.py +++ b/smoke_testing/test_version.py @@ -1,17 +1,18 @@ import os import subprocess import smoke_utils +import sys -def run(): +def run(kubescape_exec:str): print("Testing version") ver = os.getenv("RELEASE") - msg = str(subprocess.check_output([smoke_utils.get_bin_cli(), "version"])) + msg = str(subprocess.check_output([kubescape_exec, "version"])) assert ver in msg, f"expected version: {ver}, found: {msg}" print("Done testing version") if __name__ == "__main__": - run() + run(kubescape_exec=smoke_utils.get_exec_from_args(sys.argv)) From 5c7d89cb9e6d992fa91c65429f74d8fb640b507d Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 1 Nov 2021 11:55:54 +0200 Subject: [PATCH 34/70] use command --- smoke_testing/smoke_utils.py | 36 ++++++++--------------------------- smoke_testing/test_command.py | 3 +-- smoke_testing/test_version.py | 4 ++-- 3 files changed, 11 insertions(+), 32 deletions(-) diff --git a/smoke_testing/smoke_utils.py b/smoke_testing/smoke_utils.py index 528193a7..3a3fda9f 100644 --- a/smoke_testing/smoke_utils.py +++ b/smoke_testing/smoke_utils.py @@ -1,34 +1,14 @@ -import platform -from os import path from sys import stderr - - -def get_build_dir(): - current_platform = platform.system() - build_dir = "build/" - - if current_platform == "Windows": build_dir += "windows-latest" - elif current_platform == "Linux": build_dir += "ubuntu-latest" - elif current_platform == "Darwin": build_dir += "macos-latest" - else: raise OSError(f"Platform {current_platform} is not supported!") - - return build_dir - - -def get_package_name(): - return "kubescape" - - -def get_bin_cli(): - return path.abspath(path.join(get_build_dir(), get_package_name())) - - -def check_status(status, msg): - if status != 0: - stderr.write(msg) - exit(status) +import subprocess def get_exec_from_args(args: list): return args[1] + +def run_command(command): + try: + return str(subprocess.check_output(command)) + except Exception as e: + return e + diff --git a/smoke_testing/test_command.py b/smoke_testing/test_command.py index 43277e5f..20be67ea 100644 --- a/smoke_testing/test_command.py +++ b/smoke_testing/test_command.py @@ -1,4 +1,3 @@ -import subprocess import smoke_utils import sys @@ -6,7 +5,7 @@ import sys def test_command(command: list): print(f"Testing \"{' '.join(command[1:])}\" command") - msg = str(subprocess.check_output(command)) + msg = smoke_utils.run_command(command) assert "unknown command" not in msg, f"{command[1:]} is missing: {msg}" assert "invalid parameter" not in msg, f"{command[1:]} is invalid: {msg}" diff --git a/smoke_testing/test_version.py b/smoke_testing/test_version.py index 689e3934..2092abeb 100644 --- a/smoke_testing/test_version.py +++ b/smoke_testing/test_version.py @@ -4,11 +4,11 @@ import smoke_utils import sys -def run(kubescape_exec:str): +def run(kubescape_exec: str): print("Testing version") ver = os.getenv("RELEASE") - msg = str(subprocess.check_output([kubescape_exec, "version"])) + msg = smoke_utils.run_command(command=[kubescape_exec, "version"]) assert ver in msg, f"expected version: {ver}, found: {msg}" print("Done testing version") From a99d2e9e26ab50fa4055708d2a8cd0fde211e0fd Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 1 Nov 2021 12:41:54 +0200 Subject: [PATCH 35/70] remove scan --- smoke_testing/test_command.py | 1 - smoke_testing/test_version.py | 1 - 2 files changed, 2 deletions(-) diff --git a/smoke_testing/test_command.py b/smoke_testing/test_command.py index 20be67ea..674d4b50 100644 --- a/smoke_testing/test_command.py +++ b/smoke_testing/test_command.py @@ -19,7 +19,6 @@ def run(kubescape_exec:str): test_command(command=[kubescape_exec, "download"]) test_command(command=[kubescape_exec, "config"]) test_command(command=[kubescape_exec, "help"]) - test_command(command=[kubescape_exec, "scan"]) test_command(command=[kubescape_exec, "scan", "framework"]) test_command(command=[kubescape_exec, "scan", "control"]) diff --git a/smoke_testing/test_version.py b/smoke_testing/test_version.py index 2092abeb..bf075027 100644 --- a/smoke_testing/test_version.py +++ b/smoke_testing/test_version.py @@ -1,5 +1,4 @@ import os -import subprocess import smoke_utils import sys From d5b60c6ac8d6a401452a81f1f9168d7ebbcfd804 Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 1 Nov 2021 13:52:40 +0200 Subject: [PATCH 36/70] update config api --- README.md | 5 +++++ cautils/getter/armoapiutils.go | 2 +- smoke_testing/smoke_utils.py | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5626034f..17eadbc3 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,11 @@ Want to contribute? Want to discuss something? Have an issue? # Options and examples +## Tutorials + +* [Overview](https://youtu.be/wdBkt_0Qhbg) +* [Scanning Kubernetes YAML files](https://youtu.be/Ox6DaR7_4ZI) + ## Install on Windows **Requires powershell v5.0+** diff --git a/cautils/getter/armoapiutils.go b/cautils/getter/armoapiutils.go index 83d43513..5699ed25 100644 --- a/cautils/getter/armoapiutils.go +++ b/cautils/getter/armoapiutils.go @@ -39,7 +39,7 @@ func (armoAPI *ArmoAPI) getAccountConfig(customerGUID, clusterName string) strin u := url.URL{} u.Scheme = "https" u.Host = armoAPI.apiURL - u.Path = "api/v1/customerConfiguration" + u.Path = "api/v1/armoCustomerConfiguration" q := u.Query() q.Add("customerGUID", customerGUID) diff --git a/smoke_testing/smoke_utils.py b/smoke_testing/smoke_utils.py index 3a3fda9f..bc9ce452 100644 --- a/smoke_testing/smoke_utils.py +++ b/smoke_testing/smoke_utils.py @@ -8,7 +8,7 @@ def get_exec_from_args(args: list): def run_command(command): try: - return str(subprocess.check_output(command)) + return f"{subprocess.check_output(command)}" except Exception as e: - return e + return f"{e}" From 67c8719f34a72ae663a8bbf683d6ce28ce251de4 Mon Sep 17 00:00:00 2001 From: dwertent Date: Mon, 1 Nov 2021 14:04:20 +0200 Subject: [PATCH 37/70] adding smoke tests to PR --- .github/workflows/master_pr_checks.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/master_pr_checks.yaml b/.github/workflows/master_pr_checks.yaml index 53fca59f..b4a3bbca 100644 --- a/.github/workflows/master_pr_checks.yaml +++ b/.github/workflows/master_pr_checks.yaml @@ -31,8 +31,9 @@ jobs: CGO_ENABLED: 0 run: python3 --version && python3 build.py - - name: Upload build artifacts - uses: actions/upload-artifact@v2 - with: - name: kubescape-${{ matrix.os }} - path: build/${{ matrix.os }}/kubescape + - name: Smoke Testing + env: + RELEASE: v1.0.${{ github.run_number }} + KUBESCAPE_SKIP_UPDATE_CHECK: "true" + run: python3 smoke_testing/init.py ${PWD}/build/${{ matrix.os }}/kubescape + \ No newline at end of file From 69099755032671dcbb526aaf5c43b9361b9d4f17 Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 2 Nov 2021 10:14:39 +0200 Subject: [PATCH 38/70] controls support yaml inputs --- README.md | 1 + cautils/scaninfo.go | 20 ++++++++++++++++++++ clihandler/cmd/control.go | 19 +++++++++++-------- clihandler/cmd/framework.go | 26 ++++++-------------------- clihandler/cmd/root.go | 1 + clihandler/cmd/scan.go | 4 +++- smoke_testing/test_scan.py | 0 7 files changed, 42 insertions(+), 29 deletions(-) create mode 100644 smoke_testing/test_scan.py diff --git a/README.md b/README.md index 17eadbc3..0e71f825 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Want to contribute? Want to discuss something? Have an issue? * [Overview](https://youtu.be/wdBkt_0Qhbg) * [Scanning Kubernetes YAML files](https://youtu.be/Ox6DaR7_4ZI) +* [Managing exceptions in the Kubescape SaaS version](https://youtu.be/OzpvxGmCR80) ## Install on Windows diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index aa4a0ad0..2fb087c1 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -1,6 +1,8 @@ package cautils import ( + "io" + "os" "path/filepath" "github.com/armosec/kubescape/cautils/getter" @@ -65,6 +67,24 @@ func (scanInfo *ScanInfo) setUseFrom() { } } } + +func (scanInfo *ScanInfo) SetInputPatterns(args []string) error { + if args[1] != "-" { + scanInfo.InputPatterns = args[1:] + } else { // store stout to file + tempFile, err := os.CreateTemp(".", "tmp-kubescape*.yaml") + if err != nil { + return err + } + defer os.Remove(tempFile.Name()) + + if _, err := io.Copy(tempFile, os.Stdin); err != nil { + return err + } + scanInfo.InputPatterns = []string{tempFile.Name()} + } + return nil +} func (scanInfo *ScanInfo) setGetter() { if len(scanInfo.UseFrom) > 0 { // load from file diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index 18a18853..8c85297d 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -32,24 +32,27 @@ var controlCmd = &cobra.Command{ flagValidationControl() scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - if len(args) < 1 { + if len(args) == 0 { scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) } else { - var controls []string - if len(args) > 0 { - controls = strings.Split(args[0], ",") - scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - scanInfo.PolicyIdentifier = setScanForFirstControl(controls) - } + controls := strings.Split(args[0], ",") + scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} + scanInfo.PolicyIdentifier = setScanForFirstControl(controls) if len(controls) > 1 { scanInfo.PolicyIdentifier = SetScanForGivenControls(controls[1:]) } + + if len(args) > 1 { + if err := scanInfo.SetInputPatterns(args); err != nil { + return err + } + } } scanInfo.FrameworkScan = false scanInfo.Init() cautils.SetSilentMode(scanInfo.Silent) - err := clihandler.CliSetup(&scanInfo) + err := clihandler.ScanCliSetup(&scanInfo) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 9b75e224..002c6537 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "io" "os" "strings" @@ -35,7 +34,7 @@ var frameworkCmd = &cobra.Command{ flagValidationFramework() scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} // If no framework provided, use all - if len(args) < 1 { + if len(args) == 0 { scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) } else { // Read frameworks from input args @@ -45,29 +44,19 @@ var frameworkCmd = &cobra.Command{ if len(frameworks) > 1 { scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(frameworks[1:]) } - } - if len(args) > 0 { - if len(args[1:]) == 0 || args[1] != "-" { - scanInfo.InputPatterns = args[1:] - } else { // store stout to file - tempFile, err := os.CreateTemp(".", "tmp-kubescape*.yaml") - if err != nil { - return err - } - defer os.Remove(tempFile.Name()) - if _, err := io.Copy(tempFile, os.Stdin); err != nil { + if len(args) > 1 { + // expected yaml/url input + if err := scanInfo.SetInputPatterns(args); err != nil { return err } - scanInfo.InputPatterns = []string{tempFile.Name()} } } scanInfo.Init() cautils.SetSilentMode(scanInfo.Silent) - err := clihandler.CliSetup(&scanInfo) + err := clihandler.ScanCliSetup(&scanInfo) if err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) + return err } return nil }, @@ -81,9 +70,6 @@ func init() { scanCmd.AddCommand(frameworkCmd) scanInfo = cautils.ScanInfo{} scanInfo.FrameworkScan = true - frameworkCmd.Flags().BoolVarP(&scanInfo.Submit, "submit", "", false, "Send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not submitted") - frameworkCmd.Flags().BoolVarP(&scanInfo.Local, "keep-local", "", false, "If you do not want your Kubescape results reported to Armo backend. Use this flag if you ran with the '--submit' flag in the past and you do not want to submit your current scan results") - frameworkCmd.Flags().StringVarP(&scanInfo.Account, "account", "", "", "Armo portal account ID. Default will load account ID from configMap or config file") } func SetScanForGivenFrameworks(frameworks []string) []reporthandling.PolicyIdentifier { for _, framework := range frameworks { diff --git a/clihandler/cmd/root.go b/clihandler/cmd/root.go index 7f51c38a..f2c88e06 100644 --- a/clihandler/cmd/root.go +++ b/clihandler/cmd/root.go @@ -31,6 +31,7 @@ func Execute() { } func init() { + scanCmd.PersistentFlags().StringVarP(&scanInfo.Account, "account", "", "", "Armo portal account ID. Default will load account ID from configMap or config file") flag.CommandLine.StringVar(&armoBEURLs, "environment", "", envFlagUsage) rootCmd.PersistentFlags().StringVar(&armoBEURLs, "environment", "", envFlagUsage) rootCmd.PersistentFlags().MarkHidden("environment") diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index 7f77ce05..5b79abbc 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -35,8 +35,10 @@ var scanCmd = &cobra.Command{ func init() { rootCmd.AddCommand(scanCmd) + scanCmd.PersistentFlags().BoolVarP(&scanInfo.Submit, "submit", "", false, "Send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not submitted") + scanCmd.PersistentFlags().BoolVarP(&scanInfo.Local, "keep-local", "", false, "If you do not want your Kubescape results reported to Armo backend. Use this flag if you ran with the '--submit' flag in the past and you do not want to submit your current scan results") scanCmd.PersistentFlags().StringVarP(&scanInfo.ExcludedNamespaces, "exclude-namespaces", "e", "", "Namespaces to exclude from scanning. Recommended: kube-system, kube-public") - scanCmd.PersistentFlags().StringVarP(&scanInfo.Format, "format", "f", "pretty-printer", `Output format. Supported formats: "pretty-printer"/"json"/"junit"`) + scanCmd.PersistentFlags().StringVarP(&scanInfo.Format, "format", "f", "pretty-printer", `Output format. Supported formats: "pretty-printer"/"json"/"junit"/"prometheus"`) scanCmd.PersistentFlags().StringVarP(&scanInfo.Output, "output", "o", "", "Output file. Print output to file and not stdout") scanCmd.PersistentFlags().BoolVarP(&scanInfo.Silent, "silent", "s", false, "Silent progress messages") scanCmd.PersistentFlags().Uint16VarP(&scanInfo.FailThreshold, "fail-threshold", "t", 0, "Failure threshold is the percent bellow which the command fails and returns exit code 1") diff --git a/smoke_testing/test_scan.py b/smoke_testing/test_scan.py new file mode 100644 index 00000000..e69de29b From 9a1b8d7ce2cf0c24ae2e76f8070608785c9ed890 Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 2 Nov 2021 16:14:09 +0200 Subject: [PATCH 39/70] support submit --- README.md | 18 +++ cautils/reporterutils.go | 5 - clihandler/cliinterfaces/submit.go | 17 +++ clihandler/cmd/rbac.go | 80 +++++++++++++ clihandler/cmd/results.go | 105 ++++++++++++++++++ clihandler/cmd/root.go | 2 +- clihandler/cmd/submit.go | 27 +++++ clihandler/initcli.go | 45 ++++---- go.mod | 2 +- resultshandling/reporter/mockreporter.go | 4 +- .../reporter/reporteventreceiver.go | 13 ++- resultshandling/results.go | 6 +- smoke_testing/init.py | 34 +++++- smoke_testing/test_command.py | 4 +- smoke_testing/test_scan.py | 20 ++++ 15 files changed, 343 insertions(+), 39 deletions(-) delete mode 100644 cautils/reporterutils.go create mode 100644 clihandler/cliinterfaces/submit.go create mode 100644 clihandler/cmd/rbac.go create mode 100644 clihandler/cmd/results.go create mode 100644 clihandler/cmd/submit.go diff --git a/README.md b/README.md index 0e71f825..8922d2f9 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,24 @@ kubescape scan framework nsa --use-from nsa.json Kubescape is an open source project, we welcome your feedback and ideas for improvement. We’re also aiming to collaborate with the Kubernetes community to help make the tests themselves more robust and complete as Kubernetes develops. +# Submit data manually + +Use the `submit` command if you wish to submit data manually + +## Submit Role-Based Access Control (RBAC) +``` +kubescape submit rbac +``` + + +## Submit scan results manually + +First, scan your cluster using the `json` format flag: `kubescape scan framework --format json --output path/to/results.json`. + +Now you can submit the results to the Kubaescape SaaS version - +``` +kubescape submit results path/to/results.json +``` # How to build ## Build using python (3.7^) script diff --git a/cautils/reporterutils.go b/cautils/reporterutils.go deleted file mode 100644 index 7e4eabe0..00000000 --- a/cautils/reporterutils.go +++ /dev/null @@ -1,5 +0,0 @@ -package cautils - -const ( - ComponentIdentifier = "Posture" -) diff --git a/clihandler/cliinterfaces/submit.go b/clihandler/cliinterfaces/submit.go new file mode 100644 index 00000000..6428ad69 --- /dev/null +++ b/clihandler/cliinterfaces/submit.go @@ -0,0 +1,17 @@ +package cliinterfaces + +import ( + "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/resultshandling/reporter" + "github.com/armosec/opa-utils/reporthandling" +) + +type ISubmitObjects interface { + SetResourcesReport() (*reporthandling.PostureReport, error) +} + +type SubmitInterfaces struct { + SubmitObjects ISubmitObjects + Reporter reporter.IReport + ClusterConfig cautils.IClusterConfig +} diff --git a/clihandler/cmd/rbac.go b/clihandler/cmd/rbac.go new file mode 100644 index 00000000..8811bde3 --- /dev/null +++ b/clihandler/cmd/rbac.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/armosec/k8s-interface/k8sinterface" + "github.com/armosec/kubescape/clihandler" + "github.com/armosec/kubescape/clihandler/cliinterfaces" + "github.com/armosec/kubescape/resultshandling/reporter" + "github.com/armosec/opa-utils/reporthandling" + "github.com/armosec/rbac-utils/rbacscanner" + uuid "github.com/satori/go.uuid" + "github.com/spf13/cobra" +) + +type RBACObjects struct { + scanner *rbacscanner.RbacScannerFromK8sAPI +} + +func NewRBACObjects(scanner *rbacscanner.RbacScannerFromK8sAPI) *RBACObjects { + return &RBACObjects{scanner: scanner} +} + +func (rbacObjects *RBACObjects) SetResourcesReport() (*reporthandling.PostureReport, error) { + resources, err := rbacObjects.scanner.ListResources() + if err != nil { + return nil, err + } + return &reporthandling.PostureReport{ + ReportID: uuid.NewV4().String(), + ReportGenerationTime: time.Now().UTC(), + CustomerGUID: rbacObjects.scanner.CustomerGUID, + ClusterName: rbacObjects.scanner.ClusterName, + RBACObjects: *resources, + }, nil +} + +// rabcCmd represents the RBAC command +var rabcCmd = &cobra.Command{ + Use: "rbac\nExample:\n$ kubescape submit rbac", + Short: "Submit cluster's Role-Based Access Control(RBAC)", + Long: ``, + RunE: func(cmd *cobra.Command, args []string) error { + + k8s := k8sinterface.NewKubernetesApi() + + // get config + clusterConfig, err := getSubmittedClusterConfig(k8s) + if err != nil { + return err + } + + clusterName := clusterConfig.GetClusterName() + customerGUID := clusterConfig.GetCustomerGUID() + + // list RBAC + rbacObjects := NewRBACObjects(rbacscanner.NewRbacScannerFromK8sAPI(k8s, customerGUID, clusterName)) + + // submit resources + r := reporter.NewReportEventReceiver(customerGUID, clusterName) + + submitInterfaces := cliinterfaces.SubmitInterfaces{ + ClusterConfig: clusterConfig, + SubmitObjects: rbacObjects, + Reporter: r, + } + + if err := clihandler.Submit(submitInterfaces); err != nil { + fmt.Println(err) + os.Exit(1) + } + return nil + }, +} + +func init() { + submitCmd.AddCommand(rabcCmd) +} diff --git a/clihandler/cmd/results.go b/clihandler/cmd/results.go new file mode 100644 index 00000000..72b8afe7 --- /dev/null +++ b/clihandler/cmd/results.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/armosec/k8s-interface/k8sinterface" + "github.com/armosec/kubescape/clihandler" + "github.com/armosec/kubescape/clihandler/cliinterfaces" + "github.com/armosec/kubescape/resultshandling/reporter" + "github.com/armosec/opa-utils/reporthandling" + uuid "github.com/satori/go.uuid" + "github.com/spf13/cobra" +) + +type ResultsObject struct { + filePath string + customerGUID string + clusterName string +} + +func NewResultsObject(customerGUID, clusterName, filePath string) *ResultsObject { + return &ResultsObject{ + filePath: filePath, + customerGUID: customerGUID, + clusterName: clusterName, + } +} + +func (resultsObject *ResultsObject) SetResourcesReport() (*reporthandling.PostureReport, error) { + // load framework results from json file + frameworkReports, err := loadResultsFromFile(resultsObject.filePath) + if err != nil { + return nil, err + } + + return &reporthandling.PostureReport{ + FrameworkReports: frameworkReports, + ReportID: uuid.NewV4().String(), + ReportGenerationTime: time.Now().UTC(), + CustomerGUID: resultsObject.customerGUID, + ClusterName: resultsObject.clusterName, + }, nil +} + +var resultsCmd = &cobra.Command{ + Use: "results \nExample:\n$ kubescape submit results path/to/results.json", + Short: "Submit a pre scanned results file. The file must be in json format", + Long: ``, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("missing results file") + } + + k8s := k8sinterface.NewKubernetesApi() + + // get config + clusterConfig, err := getSubmittedClusterConfig(k8s) + if err != nil { + return err + } + + clusterName := clusterConfig.GetClusterName() + customerGUID := clusterConfig.GetCustomerGUID() + + resultsObjects := NewResultsObject(customerGUID, clusterName, args[0]) + + // submit resources + r := reporter.NewReportEventReceiver(customerGUID, clusterName) + + submitInterfaces := cliinterfaces.SubmitInterfaces{ + ClusterConfig: clusterConfig, + SubmitObjects: resultsObjects, + Reporter: r, + } + + if err := clihandler.Submit(submitInterfaces); err != nil { + fmt.Println(err) + os.Exit(1) + } + return nil + }, +} + +func init() { + submitCmd.AddCommand(resultsCmd) +} + +func loadResultsFromFile(filePath string) ([]reporthandling.FrameworkReport, error) { + frameworkReports := []reporthandling.FrameworkReport{} + f, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + if err = json.Unmarshal(f, &frameworkReports); err != nil { + frameworkReport := reporthandling.FrameworkReport{} + if err = json.Unmarshal(f, &frameworkReport); err != nil { + return frameworkReports, err + } + frameworkReports = append(frameworkReports, frameworkReport) + } + return frameworkReports, nil +} diff --git a/clihandler/cmd/root.go b/clihandler/cmd/root.go index f2c88e06..dafd262f 100644 --- a/clihandler/cmd/root.go +++ b/clihandler/cmd/root.go @@ -31,7 +31,7 @@ func Execute() { } func init() { - scanCmd.PersistentFlags().StringVarP(&scanInfo.Account, "account", "", "", "Armo portal account ID. Default will load account ID from configMap or config file") + rootCmd.PersistentFlags().StringVarP(&scanInfo.Account, "account", "", "", "Armo portal account ID. Default will load account ID from configMap or config file") flag.CommandLine.StringVar(&armoBEURLs, "environment", "", envFlagUsage) rootCmd.PersistentFlags().StringVar(&armoBEURLs, "environment", "", envFlagUsage) rootCmd.PersistentFlags().MarkHidden("environment") diff --git a/clihandler/cmd/submit.go b/clihandler/cmd/submit.go new file mode 100644 index 00000000..f0b07ae8 --- /dev/null +++ b/clihandler/cmd/submit.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/armosec/k8s-interface/k8sinterface" + "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/cautils/getter" + "github.com/spf13/cobra" +) + +var submitCmd = &cobra.Command{ + Use: "submit ", + Short: "Submit an object to the Kubescape SaaS version", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + }, +} + +func init() { + rootCmd.AddCommand(submitCmd) +} + +func getSubmittedClusterConfig(k8s *k8sinterface.KubernetesApi) (*cautils.ClusterConfig, error) { + clusterConfig := cautils.NewClusterConfig(k8s, getter.GetArmoAPIConnector()) + clusterConfig.LoadConfig() + err := clusterConfig.SetConfig(scanInfo.Account) + return clusterConfig, err +} diff --git a/clihandler/initcli.go b/clihandler/initcli.go index a502cc74..6d165e44 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -9,6 +9,7 @@ import ( "github.com/armosec/k8s-interface/k8sinterface" "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/cautils/getter" + "github.com/armosec/kubescape/clihandler/cliinterfaces" "github.com/armosec/kubescape/opaprocessor" "github.com/armosec/kubescape/policyhandler" "github.com/armosec/kubescape/resourcehandler" @@ -18,11 +19,6 @@ import ( "github.com/armosec/opa-utils/reporthandling" ) -type CLIHandler struct { - policyHandler *policyhandler.PolicyHandler - scanInfo *cautils.ScanInfo -} - var SupportedFrameworks = []string{"nsa", "mitre"} var ValidFrameworks = strings.Join(SupportedFrameworks, ", ") @@ -41,7 +37,7 @@ func getReporter(scanInfo *cautils.ScanInfo) reporter.IReport { return reporter.NewReportMock() } - return reporter.NewReportEventReceiver() + return reporter.NewReportEventReceiver("", "") } func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { var resourceHandler resourcehandler.IResourceHandler @@ -78,7 +74,7 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { } } -func CliSetup(scanInfo *cautils.ScanInfo) error { +func ScanCliSetup(scanInfo *cautils.ScanInfo) error { interfaces := getInterfaces(scanInfo) @@ -97,8 +93,8 @@ func CliSetup(scanInfo *cautils.ScanInfo) error { go func() { // policy handler setup policyHandler := policyhandler.NewPolicyHandler(&processNotification, interfaces.resourceHandler) - cli := NewCLIHandler(policyHandler, scanInfo) - if err := cli.Scan(); err != nil { + + if err := Scan(policyHandler, scanInfo); err != nil { fmt.Println(err) os.Exit(1) } @@ -124,23 +120,16 @@ func CliSetup(scanInfo *cautils.ScanInfo) error { return nil } -func NewCLIHandler(policyHandler *policyhandler.PolicyHandler, scanInfo *cautils.ScanInfo) *CLIHandler { - return &CLIHandler{ - scanInfo: scanInfo, - policyHandler: policyHandler, - } -} - -func (clihandler *CLIHandler) Scan() error { +func Scan(policyHandler *policyhandler.PolicyHandler, scanInfo *cautils.ScanInfo) error { cautils.ScanStartDisplay() policyNotification := &reporthandling.PolicyNotification{ NotificationType: reporthandling.TypeExecPostureScan, - Rules: clihandler.scanInfo.PolicyIdentifier, + Rules: scanInfo.PolicyIdentifier, Designators: armotypes.PortalDesignator{}, } switch policyNotification.NotificationType { case reporthandling.TypeExecPostureScan: - if err := clihandler.policyHandler.HandleNotificationRequest(policyNotification, clihandler.scanInfo); err != nil { + if err := policyHandler.HandleNotificationRequest(policyNotification, scanInfo); err != nil { return err } @@ -149,3 +138,21 @@ func (clihandler *CLIHandler) Scan() error { } return nil } + +func Submit(submitInterfaces cliinterfaces.SubmitInterfaces) error { + + // list resources + postureReport, err := submitInterfaces.SubmitObjects.SetResourcesReport() + if err != nil { + return err + } + + // report + if err := submitInterfaces.Reporter.ActionSendReport(&cautils.OPASessionObj{PostureReport: postureReport}); err != nil { + return err + } + fmt.Printf("\nData has been submitted successfully") + submitInterfaces.ClusterConfig.GenerateURL() + + return nil +} diff --git a/go.mod b/go.mod index 95cbcbd8..c907f411 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/armosec/armoapi-go v0.0.8 github.com/armosec/k8s-interface v0.0.8 github.com/armosec/opa-utils v0.0.21 + github.com/armosec/rbac-utils v0.0.1 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 github.com/enescakir/emoji v1.0.0 @@ -32,7 +33,6 @@ require ( github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect - github.com/armosec/rbac-utils v0.0.1 // indirect github.com/armosec/utils-k8s-go v0.0.1 // indirect github.com/coreos/go-oidc v2.2.1+incompatible // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/resultshandling/reporter/mockreporter.go b/resultshandling/reporter/mockreporter.go index 21e37bb5..3059aeb2 100644 --- a/resultshandling/reporter/mockreporter.go +++ b/resultshandling/reporter/mockreporter.go @@ -8,7 +8,9 @@ type ReportMock struct { func NewReportMock() *ReportMock { return &ReportMock{} } -func (reportMock *ReportMock) ActionSendReport(opaSessionObj *cautils.OPASessionObj) {} +func (reportMock *ReportMock) ActionSendReport(opaSessionObj *cautils.OPASessionObj) error { + return nil +} func (reportMock *ReportMock) SetCustomerGUID(customerGUID string) { } diff --git a/resultshandling/reporter/reporteventreceiver.go b/resultshandling/reporter/reporteventreceiver.go index 070ba745..6ce5a08d 100644 --- a/resultshandling/reporter/reporteventreceiver.go +++ b/resultshandling/reporter/reporteventreceiver.go @@ -11,7 +11,7 @@ import ( ) type IReport interface { - ActionSendReport(opaSessionObj *cautils.OPASessionObj) + ActionSendReport(opaSessionObj *cautils.OPASessionObj) error SetCustomerGUID(customerGUID string) SetClusterName(clusterName string) } @@ -22,21 +22,24 @@ type ReportEventReceiver struct { customerGUID string } -func NewReportEventReceiver() *ReportEventReceiver { +func NewReportEventReceiver(customerGUID, clusterName string) *ReportEventReceiver { return &ReportEventReceiver{ - httpClient: http.Client{}, + httpClient: http.Client{}, + clusterName: clusterName, + customerGUID: customerGUID, } } -func (report *ReportEventReceiver) ActionSendReport(opaSessionObj *cautils.OPASessionObj) { +func (report *ReportEventReceiver) ActionSendReport(opaSessionObj *cautils.OPASessionObj) error { // Remove data before reporting keepFields := []string{"kind", "apiVersion", "metadata"} keepMetadataFields := []string{"name", "namespace", "labels"} opaSessionObj.PostureReport.RemoveData(keepFields, keepMetadataFields) if err := report.send(opaSessionObj.PostureReport); err != nil { - fmt.Println(err) + return err } + return nil } func (report *ReportEventReceiver) SetCustomerGUID(customerGUID string) { diff --git a/resultshandling/results.go b/resultshandling/results.go index 1ba9aacf..01ecea34 100644 --- a/resultshandling/results.go +++ b/resultshandling/results.go @@ -1,6 +1,8 @@ package resultshandling import ( + "fmt" + "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/resultshandling/printer" "github.com/armosec/kubescape/resultshandling/reporter" @@ -27,7 +29,9 @@ func (resultsHandler *ResultsHandler) HandleResults(scanInfo *cautils.ScanInfo) resultsHandler.printerObj.ActionPrint(opaSessionObj) - resultsHandler.reporterObj.ActionSendReport(opaSessionObj) + if err := resultsHandler.reporterObj.ActionSendReport(opaSessionObj); err != nil { + fmt.Println(err) + } // TODO - get score from table score := CalculatePostureScore(opaSessionObj.PostureReport) diff --git a/smoke_testing/init.py b/smoke_testing/init.py index 3f88c756..ba29b9b3 100644 --- a/smoke_testing/init.py +++ b/smoke_testing/init.py @@ -1,11 +1,25 @@ +""" +Kubescape smoke testing + +Execute all tests: +python init.py path/the/bin/kubescape + +Execute single test: +python test_.py path/the/bin/kubescape + +Add a new test: +1. Create a python file with test_ prefix +2. Implement a function named run() + +""" + import sys import smoke_utils +import glob +import os - -tests_pkg = [ - "test_command" - , "test_version" -] +# get all python files in dir that begin with test_ +tests_pkg = list(map(lambda x: os.path.splitext(os.path.basename(x))[0], glob.glob(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'test_*.py')))) def run(**kwargs): @@ -15,4 +29,14 @@ def run(**kwargs): if __name__ == "__main__": + # the first argument should be the kubescape binary path run(kubescape_exec=smoke_utils.get_exec_from_args(sys.argv)) + +''' +Tests to add: +1. Scan E2E +2. Download and add --use-from + +3. Test formats + output +4. Test -t +''' \ No newline at end of file diff --git a/smoke_testing/test_command.py b/smoke_testing/test_command.py index 674d4b50..0bb00ff8 100644 --- a/smoke_testing/test_command.py +++ b/smoke_testing/test_command.py @@ -21,7 +21,9 @@ def run(kubescape_exec:str): test_command(command=[kubescape_exec, "help"]) test_command(command=[kubescape_exec, "scan", "framework"]) test_command(command=[kubescape_exec, "scan", "control"]) - + test_command(command=[kubescape_exec, "submit", "results"]) + test_command(command=[kubescape_exec, "submit", "rbac"]) + print("Done testing commands") diff --git a/smoke_testing/test_scan.py b/smoke_testing/test_scan.py index e69de29b..2d11d8cf 100644 --- a/smoke_testing/test_scan.py +++ b/smoke_testing/test_scan.py @@ -0,0 +1,20 @@ +import os +import smoke_utils +import sys + + +def full_scan(kubescape_exec: str): + return smoke_utils.run_command(command=[kubescape_exec, "scan", "framework", "nsa", os.path.join("..", "*.yaml")]) + + + +def run(kubescape_exec: str): + # return + print("Testing E2E yaml files") + msg = full_scan(kubescape_exec=kubescape_exec) + assert "exit status 1" not in msg, msg + print("Done E2E yaml files") + + +if __name__ == "__main__": + run(kubescape_exec=smoke_utils.get_exec_from_args(sys.argv)) From fc4edb12f99fdbed9bf08dca2d3a0e10ea066dcd Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 2 Nov 2021 18:59:34 +0200 Subject: [PATCH 40/70] adding stdout to smoke tests --- smoke_testing/smoke_utils.py | 3 +-- smoke_testing/test_scan.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/smoke_testing/smoke_utils.py b/smoke_testing/smoke_utils.py index bc9ce452..7b56349e 100644 --- a/smoke_testing/smoke_utils.py +++ b/smoke_testing/smoke_utils.py @@ -1,4 +1,3 @@ -from sys import stderr import subprocess @@ -8,7 +7,7 @@ def get_exec_from_args(args: list): def run_command(command): try: - return f"{subprocess.check_output(command)}" + return f"{subprocess.check_output(command, stderr=subprocess.STDOUT)}" except Exception as e: return f"{e}" diff --git a/smoke_testing/test_scan.py b/smoke_testing/test_scan.py index 2d11d8cf..d886d0da 100644 --- a/smoke_testing/test_scan.py +++ b/smoke_testing/test_scan.py @@ -7,7 +7,6 @@ def full_scan(kubescape_exec: str): return smoke_utils.run_command(command=[kubescape_exec, "scan", "framework", "nsa", os.path.join("..", "*.yaml")]) - def run(kubescape_exec: str): # return print("Testing E2E yaml files") From 54648bb9738cca55c55c1d5d4749730a8b68d07a Mon Sep 17 00:00:00 2001 From: dwertent Date: Wed, 3 Nov 2021 15:28:42 +0200 Subject: [PATCH 41/70] update opa pkg --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c907f411..179abd04 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.17 require ( github.com/armosec/armoapi-go v0.0.8 github.com/armosec/k8s-interface v0.0.8 - github.com/armosec/opa-utils v0.0.21 + github.com/armosec/opa-utils v0.0.24 github.com/armosec/rbac-utils v0.0.1 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 diff --git a/go.sum b/go.sum index f46de3bb..9fc77b98 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/armosec/armoapi-go v0.0.8 h1:JPa9rZynuE2RucamDh6dsy/sjCScmWDsyt1zagJF github.com/armosec/armoapi-go v0.0.8/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= github.com/armosec/k8s-interface v0.0.8 h1:Eo3Qen4yFXxzVem49FNeij2ckyzHSAJ0w6PZMaSEIm8= github.com/armosec/k8s-interface v0.0.8/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= -github.com/armosec/opa-utils v0.0.21 h1:LzQ4LoY+fKQIhyLdR7cI/YfjdCztLdAeP40Ct3Wcw5o= -github.com/armosec/opa-utils v0.0.21/go.mod h1:JaE2a0kB2O22JZCqBBfOS9Pvh9rXs9xXXb9lVo01rTs= +github.com/armosec/opa-utils v0.0.24 h1:F36RpNVZpeUoEKV1j0xRDql60oi0mPm0whycu8+DL1s= +github.com/armosec/opa-utils v0.0.24/go.mod h1:JaE2a0kB2O22JZCqBBfOS9Pvh9rXs9xXXb9lVo01rTs= github.com/armosec/rbac-utils v0.0.1 h1:N2MI98F/0zbDjmRZ29CNElU1AXkFLk5csd/qAHOBdXY= github.com/armosec/rbac-utils v0.0.1/go.mod h1:pQ8CBiij8kSKV7aeZm9FMvtZN28VgA7LZcYyTWimq40= github.com/armosec/utils-go v0.0.2/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= From 9d20fd41a85862e808a812ef60852239f253a30e Mon Sep 17 00:00:00 2001 From: dwertent Date: Wed, 3 Nov 2021 17:28:37 +0200 Subject: [PATCH 42/70] fixed rbac submit --- clihandler/cmd/rbac.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clihandler/cmd/rbac.go b/clihandler/cmd/rbac.go index 8811bde3..a8c97e37 100644 --- a/clihandler/cmd/rbac.go +++ b/clihandler/cmd/rbac.go @@ -39,7 +39,7 @@ func (rbacObjects *RBACObjects) SetResourcesReport() (*reporthandling.PostureRep // rabcCmd represents the RBAC command var rabcCmd = &cobra.Command{ - Use: "rbac\nExample:\n$ kubescape submit rbac", + Use: "rbac \nExample:\n$ kubescape submit rbac", Short: "Submit cluster's Role-Based Access Control(RBAC)", Long: ``, RunE: func(cmd *cobra.Command, args []string) error { From 1c30528eeacd767206948e4be651b994c866c338 Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 4 Nov 2021 12:06:34 +0200 Subject: [PATCH 43/70] support include ns --- README.md | 18 ++++++-- cautils/scaninfo.go | 1 + clihandler/cmd/scan.go | 3 +- clihandler/initcli.go | 13 +++++- resourcehandler/fieldselector.go | 76 ++++++++++++++++++++++++++++++++ resourcehandler/k8sresources.go | 40 +++++------------ 6 files changed, 117 insertions(+), 34 deletions(-) create mode 100644 resourcehandler/fieldselector.go diff --git a/README.md b/README.md index 8922d2f9..3ce34d2a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser | flag | default | description | options | |-----------------------------|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------| | `-e`/`--exclude-namespaces` | Scan all namespaces | Namespaces to exclude from scanning. Recommended to exclude `kube-system` and `kube-public` namespaces | | +| `--include-namespaces` | Scan all namespaces | Scan specific namespaces | | | `-s`/`--silent` | Display progress messages | Silent progress messages | | | `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result bellow threshold | `0` -> `100` | | `-f`/`--format` | `pretty-printer` | Output format | `pretty-printer`/`json`/`junit`/`prometheus` | @@ -110,12 +111,21 @@ kubescape scan framework mitre --submit kubescape scan control "Privileged container" ``` +* Scan specific namespaces +``` +kubescape scan framework nsa --include-namespaces development,staging,production +``` + +* Scan cluster and exclude some namespaces +``` +kubescape scan framework nsa --exclude-namespaces kube-system,kube-public +``` + * Scan local `yaml`/`json` files before deploying. [Take a look at the demonstration](https://youtu.be/Ox6DaR7_4ZI) ``` kubescape scan framework nsa *.yaml ``` - * Scan kubernetes manifest files from a public github repository ``` kubescape scan framework nsa https://github.com/armosec/kubescape @@ -123,17 +133,17 @@ kubescape scan framework nsa https://github.com/armosec/kubescape * Output in `json` format ``` -kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --format json --output results.json +kubescape scan framework nsa --format json --output results.json ``` * Output in `junit xml` format ``` -kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --format junit --output results.xml +kubescape scan framework nsa --format junit --output results.xml ``` * Output in `prometheus` metrics format ``` -kubescape scan framework nsa --exclude-namespaces kube-system,kube-public --format prometheus +kubescape scan framework nsa --format prometheus ``` * Scan with exceptions, objects with exceptions will be presented as `exclude` and not `fail` diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index 2fb087c1..041aef05 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -19,6 +19,7 @@ type ScanInfo struct { Format string // Format results (table, json, junit ...) Output string // Store results in an output file, Output file name ExcludedNamespaces string // DEPRECATED? + IncludeNamespaces string // DEPRECATED? InputPatterns []string // Yaml files input patterns Silent bool // Silent mode - Do not print progress logs FailThreshold uint16 // Failure score threshold diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index 5b79abbc..38f1b5d8 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -37,7 +37,8 @@ func init() { rootCmd.AddCommand(scanCmd) scanCmd.PersistentFlags().BoolVarP(&scanInfo.Submit, "submit", "", false, "Send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not submitted") scanCmd.PersistentFlags().BoolVarP(&scanInfo.Local, "keep-local", "", false, "If you do not want your Kubescape results reported to Armo backend. Use this flag if you ran with the '--submit' flag in the past and you do not want to submit your current scan results") - scanCmd.PersistentFlags().StringVarP(&scanInfo.ExcludedNamespaces, "exclude-namespaces", "e", "", "Namespaces to exclude from scanning. Recommended: kube-system, kube-public") + scanCmd.PersistentFlags().StringVarP(&scanInfo.ExcludedNamespaces, "exclude-namespaces", "e", "", "Namespaces to exclude from scanning. Recommended: kube-system,kube-public") + scanCmd.PersistentFlags().StringVar(&scanInfo.IncludeNamespaces, "include-namespaces", "", "scan specific namespaces. e.g: --include-namespaces ns-a,ns-b") scanCmd.PersistentFlags().StringVarP(&scanInfo.Format, "format", "f", "pretty-printer", `Output format. Supported formats: "pretty-printer"/"json"/"junit"/"prometheus"`) scanCmd.PersistentFlags().StringVarP(&scanInfo.Output, "output", "o", "", "Output file. Print output to file and not stdout") scanCmd.PersistentFlags().BoolVarP(&scanInfo.Silent, "silent", "s", false, "Silent progress messages") diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 6d165e44..1cd4297a 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -39,6 +39,17 @@ func getReporter(scanInfo *cautils.ScanInfo) reporter.IReport { return reporter.NewReportEventReceiver("", "") } + +func getFieldSelector(scanInfo *cautils.ScanInfo) resourcehandler.IFieldSelector { + if scanInfo.IncludeNamespaces != "" { + return resourcehandler.NewIncludeSelector(scanInfo.IncludeNamespaces) + } + if scanInfo.ExcludedNamespaces != "" { + return resourcehandler.NewExcludeSelector(scanInfo.ExcludedNamespaces) + } + + return &resourcehandler.EmptySelector{} +} func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { var resourceHandler resourcehandler.IResourceHandler var clusterConfig cautils.IClusterConfig @@ -55,7 +66,7 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { reportHandler = reporter.NewReportMock() } else { k8s := k8sinterface.NewKubernetesApi() - resourceHandler = resourcehandler.NewK8sResourceHandler(k8s, scanInfo.ExcludedNamespaces) + resourceHandler = resourcehandler.NewK8sResourceHandler(k8s, getFieldSelector(scanInfo)) clusterConfig = cautils.ClusterConfigSetup(scanInfo, k8s, getter.GetArmoAPIConnector()) // setup reporter diff --git a/resourcehandler/fieldselector.go b/resourcehandler/fieldselector.go new file mode 100644 index 00000000..cfb2efa3 --- /dev/null +++ b/resourcehandler/fieldselector.go @@ -0,0 +1,76 @@ +package resourcehandler + +import ( + "fmt" + "strings" + + "github.com/armosec/k8s-interface/k8sinterface" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type IFieldSelector interface { + GetNamespacesSelector(*schema.GroupVersionResource) string +} + +type EmptySelector struct { +} + +func (es *EmptySelector) GetNamespacesSelector(resource *schema.GroupVersionResource) string { + return "" +} + +type ExcludeSelector struct { + namespace string +} + +func NewExcludeSelector(ns string) *ExcludeSelector { + return &ExcludeSelector{namespace: ns} +} + +type IncludeSelector struct { + namespace string +} + +func NewIncludeSelector(ns string) *IncludeSelector { + return &IncludeSelector{namespace: ns} +} +func (es *ExcludeSelector) GetNamespacesSelector(resource *schema.GroupVersionResource) string { + return getNamespacesSelector(resource, es.namespace, "!=") +} + +func (is *IncludeSelector) GetNamespacesSelector(resource *schema.GroupVersionResource) string { + return getNamespacesSelector(resource, is.namespace, "==") +} + +func getNamespacesSelector(resource *schema.GroupVersionResource, ns, operator string) string { + fieldSelectors := "" + fieldSelector := "metadata." + if resource.Resource == "namespaces" { + fieldSelector += "name" + } else if k8sinterface.IsNamespaceScope(resource.Group, resource.Resource) { + fieldSelector += "namespace" + } else { + return "" + } + namespacesSlice := strings.Split(ns, ",") + for _, n := range namespacesSlice { + fieldSelectors += fmt.Sprintf("%s!=%s,", fieldSelector, n) + } + return fieldSelectors + +} +func setFieldSelector(listOptions *metav1.ListOptions, resource *schema.GroupVersionResource, excludedNamespaces string) { + fieldSelector := "metadata." + if resource.Resource == "namespaces" { + fieldSelector += "name" + } else if k8sinterface.IsNamespaceScope(resource.Group, resource.Resource) { + fieldSelector += "namespace" + } else { + return + } + excludedNamespacesSlice := strings.Split(excludedNamespaces, ",") + for _, excludedNamespace := range excludedNamespacesSlice { + listOptions.FieldSelector += fmt.Sprintf("%s!=%s,", fieldSelector, excludedNamespace) + } +} diff --git a/resourcehandler/k8sresources.go b/resourcehandler/k8sresources.go index 74bc4922..5b6c0d08 100644 --- a/resourcehandler/k8sresources.go +++ b/resourcehandler/k8sresources.go @@ -3,7 +3,6 @@ package resourcehandler import ( "context" "fmt" - "strings" "github.com/armosec/kubescape/cautils" "github.com/armosec/opa-utils/reporthandling" @@ -21,14 +20,14 @@ import ( ) type K8sResourceHandler struct { - k8s *k8sinterface.KubernetesApi - excludedNamespaces string // excluded namespaces (separated by comma) + k8s *k8sinterface.KubernetesApi + fieldSelector IFieldSelector } -func NewK8sResourceHandler(k8s *k8sinterface.KubernetesApi, excludedNamespaces string) *K8sResourceHandler { +func NewK8sResourceHandler(k8s *k8sinterface.KubernetesApi, fieldSelector IFieldSelector) *K8sResourceHandler { return &K8sResourceHandler{ - k8s: k8s, - excludedNamespaces: excludedNamespaces, + k8s: k8s, + fieldSelector: fieldSelector, } } @@ -43,7 +42,7 @@ func (k8sHandler *K8sResourceHandler) GetResources(frameworks []reporthandling.F _, namespace, labels := armotypes.DigestPortalDesignator(designator) // pull k8s recourses - if err := k8sHandler.pullResources(k8sResourcesMap, namespace, labels, k8sHandler.excludedNamespaces); err != nil { + if err := k8sHandler.pullResources(k8sResourcesMap, namespace, labels); err != nil { return k8sResourcesMap, err } @@ -59,13 +58,13 @@ func (k8sHandler *K8sResourceHandler) GetClusterAPIServerInfo() *version.Info { } return clusterAPIServerInfo } -func (k8sHandler *K8sResourceHandler) pullResources(k8sResources *cautils.K8SResources, namespace string, labels map[string]string, excludedNamespaces string) error { +func (k8sHandler *K8sResourceHandler) pullResources(k8sResources *cautils.K8SResources, namespace string, labels map[string]string) error { var errs error for groupResource := range *k8sResources { apiGroup, apiVersion, resource := k8sinterface.StringToResourceGroup(groupResource) gvr := schema.GroupVersionResource{Group: apiGroup, Version: apiVersion, Resource: resource} - result, err := k8sHandler.pullSingleResource(&gvr, namespace, labels, excludedNamespaces) + result, err := k8sHandler.pullSingleResource(&gvr, namespace, labels) if err != nil { // handle error if errs == nil { @@ -81,13 +80,13 @@ func (k8sHandler *K8sResourceHandler) pullResources(k8sResources *cautils.K8SRes return errs } -func (k8sHandler *K8sResourceHandler) pullSingleResource(resource *schema.GroupVersionResource, namespace string, labels map[string]string, excludedNamespaces string) ([]unstructured.Unstructured, error) { +func (k8sHandler *K8sResourceHandler) pullSingleResource(resource *schema.GroupVersionResource, namespace string, labels map[string]string) ([]unstructured.Unstructured, error) { // set labels listOptions := metav1.ListOptions{} - if excludedNamespaces != "" { - setFieldSelector(&listOptions, resource, excludedNamespaces) - } + + listOptions.FieldSelector += k8sHandler.fieldSelector.GetNamespacesSelector(resource) + if len(labels) > 0 { set := k8slabels.Set(labels) listOptions.LabelSelector = set.AsSelector().String() @@ -110,18 +109,3 @@ func (k8sHandler *K8sResourceHandler) pullSingleResource(resource *schema.GroupV return result.Items, nil } - -func setFieldSelector(listOptions *metav1.ListOptions, resource *schema.GroupVersionResource, excludedNamespaces string) { - fieldSelector := "metadata." - if resource.Resource == "namespaces" { - fieldSelector += "name" - } else if k8sinterface.IsNamespaceScope(resource.Group, resource.Resource) { - fieldSelector += "namespace" - } else { - return - } - excludedNamespacesSlice := strings.Split(excludedNamespaces, ",") - for _, excludedNamespace := range excludedNamespacesSlice { - listOptions.FieldSelector += fmt.Sprintf("%s!=%s,", fieldSelector, excludedNamespace) - } -} From 38a9c11286ce1ef75308c1934797aa711f0467dd Mon Sep 17 00:00:00 2001 From: Matt Boersma Date: Fri, 5 Nov 2021 10:33:06 -0600 Subject: [PATCH 44/70] Fix spelling in --fail-threshold description --- README.md | 2 +- clihandler/cmd/scan.go | 2 +- clihandler/initcli.go | 2 +- docs/run-options.md | 4 +--- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6bcc3cb6..cc0786a5 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser | --- | --- | --- | --- | | `-e`/`--exclude-namespaces` | Scan all namespaces | Namespaces to exclude from scanning. Recommended to exclude `kube-system` and `kube-public` namespaces | | `-s`/`--silent` | Display progress messages | Silent progress messages | -| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result bellow threshold| `0` -> `100` | +| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result is below threshold| `0` -> `100` | | `-f`/`--format` | `pretty-printer` | Output format | `pretty-printer`/`json`/`junit` | | `-o`/`--output` | print to stdout | Save scan result in file | | `--use-from` | | Load local framework object from specified path. If not used will download latest | diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index e86e5a02..1e1056fa 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -34,7 +34,7 @@ func init() { scanCmd.PersistentFlags().StringVarP(&scanInfo.Format, "format", "f", "pretty-printer", `Output format. Supported formats: "pretty-printer"/"json"/"junit"`) scanCmd.PersistentFlags().StringVarP(&scanInfo.Output, "output", "o", "", "Output file. Print output to file and not stdout") scanCmd.PersistentFlags().BoolVarP(&scanInfo.Silent, "silent", "s", false, "Silent progress messages") - scanCmd.PersistentFlags().Uint16VarP(&scanInfo.FailThreshold, "fail-threshold", "t", 0, "Failure threshold is the percent bellow which the command fails and returns exit code 1") + scanCmd.PersistentFlags().Uint16VarP(&scanInfo.FailThreshold, "fail-threshold", "t", 0, "Failure threshold is the percent below which the command fails and returns exit code 1") scanCmd.PersistentFlags().StringVar(&scanInfo.UseFrom, "use-from", "", "Load local framework object from specified path. If not used will download latest") scanCmd.PersistentFlags().BoolVar(&scanInfo.UseDefault, "use-default", false, "Load local framework object from default path. If not used will download latest") scanCmd.PersistentFlags().StringVar(&scanInfo.UseExceptions, "exceptions", "", "Path to an exceptions obj. If not set will download exceptions from Armo management portal") diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 1fdaae44..7e74ebb0 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -118,7 +118,7 @@ func CliSetup(scanInfo *cautils.ScanInfo) error { adjustedFailThreshold := float32(scanInfo.FailThreshold) / 100 if score < adjustedFailThreshold { - return fmt.Errorf("Scan score is bellow threshold") + return fmt.Errorf("Scan score is below threshold") } return nil diff --git a/docs/run-options.md b/docs/run-options.md index 8a94afc0..9233f49b 100644 --- a/docs/run-options.md +++ b/docs/run-options.md @@ -13,7 +13,7 @@ kubescape scan framework nsa --exclude-namespaces kube-system,kube-public | --- | --- | --- | --- | | `-e`/`--exclude-namespaces` | Scan all namespaces | Namespaces to exclude from scanning. Recommended to exclude `kube-system` and `kube-public` namespaces | | `-s`/`--silent` | Display progress messages | Silent progress messages | -| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result bellow threshold| `0` -> `100` | +| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result is below threshold| `0` -> `100` | | `-f`/`--format` | `pretty-printer` | Output format | `pretty-printer`/`json`/`junit` | | `-o`/`--output` | print to stdout | Save scan result in file | | `--use-from` | | Load local framework object from specified path. If not used will download latest | @@ -85,5 +85,3 @@ kubescape scan framework nsa --use-from nsa.json ``` Kubescape is an open source project, we welcome your feedback and ideas for improvement. We’re also aiming to collaborate with the Kubernetes community to help make the tests themselves more robust and complete as Kubernetes develops. - - From b0f6357482e46be90b191615bd772285a5eed54b Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 7 Nov 2021 09:13:51 +0200 Subject: [PATCH 45/70] support armoBest --- clihandler/initcli.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 1cd4297a..153eeeb1 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -19,7 +19,7 @@ import ( "github.com/armosec/opa-utils/reporthandling" ) -var SupportedFrameworks = []string{"nsa", "mitre"} +var SupportedFrameworks = []string{"nsa", "mitre", "armobest"} var ValidFrameworks = strings.Join(SupportedFrameworks, ", ") type componentInterfaces struct { From e4c3935a1b77f697c1a53a8b20f7ea5dac60f810 Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 7 Nov 2021 13:50:46 +0200 Subject: [PATCH 46/70] fixed include ns --- resourcehandler/fieldselector.go | 17 +---------------- smoke_testing/init.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/resourcehandler/fieldselector.go b/resourcehandler/fieldselector.go index cfb2efa3..fec72d4e 100644 --- a/resourcehandler/fieldselector.go +++ b/resourcehandler/fieldselector.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/armosec/k8s-interface/k8sinterface" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" ) @@ -55,22 +54,8 @@ func getNamespacesSelector(resource *schema.GroupVersionResource, ns, operator s } namespacesSlice := strings.Split(ns, ",") for _, n := range namespacesSlice { - fieldSelectors += fmt.Sprintf("%s!=%s,", fieldSelector, n) + fieldSelectors += fmt.Sprintf("%s%s%s,", fieldSelector, operator, n) } return fieldSelectors } -func setFieldSelector(listOptions *metav1.ListOptions, resource *schema.GroupVersionResource, excludedNamespaces string) { - fieldSelector := "metadata." - if resource.Resource == "namespaces" { - fieldSelector += "name" - } else if k8sinterface.IsNamespaceScope(resource.Group, resource.Resource) { - fieldSelector += "namespace" - } else { - return - } - excludedNamespacesSlice := strings.Split(excludedNamespaces, ",") - for _, excludedNamespace := range excludedNamespacesSlice { - listOptions.FieldSelector += fmt.Sprintf("%s!=%s,", fieldSelector, excludedNamespace) - } -} diff --git a/smoke_testing/init.py b/smoke_testing/init.py index ba29b9b3..573212c3 100644 --- a/smoke_testing/init.py +++ b/smoke_testing/init.py @@ -33,10 +33,15 @@ if __name__ == "__main__": run(kubescape_exec=smoke_utils.get_exec_from_args(sys.argv)) ''' -Tests to add: -1. Scan E2E -2. Download and add --use-from +Supported tests: +1. Commands +2. Version number +3. E2E yaml scanning -3. Test formats + output -4. Test -t +TODO: +1. Test formats + output +2. Test --fail-threshold +3. Test known supported FW +4. Test FW list +5. Test Controls list ''' \ No newline at end of file From 8b46a49e237251ace52aef4662e264a7a529c1c9 Mon Sep 17 00:00:00 2001 From: Yonah Dissen Date: Sun, 7 Nov 2021 21:09:30 +0200 Subject: [PATCH 47/70] add helm chart to deploy kubescape in cluster --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6bcc3cb6..c2cb9045 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,11 @@ git clone https://github.com/armosec/kubescape.git kubescape && cd "$_" docker build -t kubescape -f build/Dockerfile . ``` +### Run using docker image +``` +docker run -v /full_path_to/example.yaml:/app/example.yaml quay.io/armosec/kubescape scan framework nsa /app/example.yaml +``` + # Under the hood ## Tests From 57160c4d0498c93bdba6e25b3f8cae8158cb5674 Mon Sep 17 00:00:00 2001 From: Yonah Dissen Date: Sun, 7 Nov 2021 21:17:45 +0200 Subject: [PATCH 48/70] add helm chart to deploy kubescape in cluster --- examples/helm_chart/Chart.yaml | 29 ++++++++ examples/helm_chart/README.md | 29 ++++++++ examples/helm_chart/templates/_helpers.tpl | 62 +++++++++++++++++ .../helm_chart/templates/clusterrole.yaml | 11 +++ .../templates/clusterrolebinding.yaml | 16 +++++ examples/helm_chart/templates/configmap.yaml | 14 ++++ examples/helm_chart/templates/cronjob.yaml | 28 ++++++++ examples/helm_chart/templates/role.yaml | 11 +++ .../helm_chart/templates/rolebinding.yaml | 16 +++++ .../helm_chart/templates/serviceaccount.yaml | 12 ++++ examples/helm_chart/values.yaml | 67 +++++++++++++++++++ 11 files changed, 295 insertions(+) create mode 100644 examples/helm_chart/Chart.yaml create mode 100644 examples/helm_chart/README.md create mode 100644 examples/helm_chart/templates/_helpers.tpl create mode 100644 examples/helm_chart/templates/clusterrole.yaml create mode 100644 examples/helm_chart/templates/clusterrolebinding.yaml create mode 100644 examples/helm_chart/templates/configmap.yaml create mode 100644 examples/helm_chart/templates/cronjob.yaml create mode 100644 examples/helm_chart/templates/role.yaml create mode 100644 examples/helm_chart/templates/rolebinding.yaml create mode 100644 examples/helm_chart/templates/serviceaccount.yaml create mode 100644 examples/helm_chart/values.yaml diff --git a/examples/helm_chart/Chart.yaml b/examples/helm_chart/Chart.yaml new file mode 100644 index 00000000..79069252 --- /dev/null +++ b/examples/helm_chart/Chart.yaml @@ -0,0 +1,29 @@ +apiVersion: v2 +name: kubescape +description: + Kubescape is the first open-source tool for testing if Kubernetes is deployed securely according to multiple frameworks + regulatory, customized company policies and DevSecOps best practices, such as the [NSA-CISA](https://www.armosec.io/blog/kubernetes-hardening-guidance-summary-by-armo) and the [MITRE ATT&CK®](https://www.microsoft.com/security/blog/2021/03/23/secure-containerized-environments-with-updated-threat-matrix-for-kubernetes/) . + Kubescape scans K8s clusters, YAML files, and HELM charts, and detect misconfigurations and software vulnerabilities at early stages of the CI/CD pipeline and provides a risk score instantly and risk trends over time. + Kubescape integrates natively with other DevOps tools, including Jenkins, CircleCI and Github workflows. + + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 1.0.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "v1.0.128" diff --git a/examples/helm_chart/README.md b/examples/helm_chart/README.md new file mode 100644 index 00000000..92c75549 --- /dev/null +++ b/examples/helm_chart/README.md @@ -0,0 +1,29 @@ +# kubescape + +![Version: 1.0.0](https://img.shields.io/badge/Version-1.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.0.128](https://img.shields.io/badge/AppVersion-v1.0.128-informational?style=flat-square) + +Kubescape is the first open-source tool for testing if Kubernetes is deployed securely according to multiple frameworks regulatory, customized company policies and DevSecOps best practices, such as the [NSA-CISA](https://www.armosec.io/blog/kubernetes-hardening-guidance-summary-by-armo) and the [MITRE ATT&CK®](https://www.microsoft.com/security/blog/2021/03/23/secure-containerized-environments-with-updated-threat-matrix-for-kubernetes/) . Kubescape scans K8s clusters, YAML files, and HELM charts, and detect misconfigurations and software vulnerabilities at early stages of the CI/CD pipeline and provides a risk score instantly and risk trends over time. Kubescape integrates natively with other DevOps tools, including Jenkins, CircleCI and Github workflows. + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | | +| configMap | object | `{"create":true,"params":{"clusterName":"","customerGUID":","}}` | ARMO customer information | +| fullnameOverride | string | `""` | | +| image | object | `{"imageName":"kubescape","pullPolicy":"IfNotPresent","repository":"quay.io/armosec","tag":"latest"}` | Image and version to deploy | +| imagePullSecrets | list | `[]` | | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | | +| podAnnotations | object | `{}` | | +| podSecurityContext | object | `{}` | | +| resources | object | `{}` | | +| schedule | string | `"0 0 * * *"` | Frequency of running the scan | +| securityContext | object | `{}` | | +| serviceAccount.annotations | object | `{}` | | +| serviceAccount.create | bool | `true` | | +| serviceAccount.name | string | `"kubescape-discovery"` | | +| tolerations | list | `[]` | | + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v1.5.0](https://github.com/norwoodj/helm-docs/releases/v1.5.0) diff --git a/examples/helm_chart/templates/_helpers.tpl b/examples/helm_chart/templates/_helpers.tpl new file mode 100644 index 00000000..b933db8a --- /dev/null +++ b/examples/helm_chart/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kubescape.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "kubescape.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kubescape.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kubescape.labels" -}} +helm.sh/chart: {{ include "kubescape.chart" . }} +{{ include "kubescape.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kubescape.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kubescape.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "kubescape.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "kubescape.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/examples/helm_chart/templates/clusterrole.yaml b/examples/helm_chart/templates/clusterrole.yaml new file mode 100644 index 00000000..fa2bf7ee --- /dev/null +++ b/examples/helm_chart/templates/clusterrole.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "kubescape.fullname" . }} + labels: + {{- include "kubescape.labels" . | nindent 4 }} +rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["get", "list", "describe"] + diff --git a/examples/helm_chart/templates/clusterrolebinding.yaml b/examples/helm_chart/templates/clusterrolebinding.yaml new file mode 100644 index 00000000..74ec8006 --- /dev/null +++ b/examples/helm_chart/templates/clusterrolebinding.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "kubescape.fullname" . }} + labels: + {{- include "kubescape.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "kubescape.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "kubescape.serviceAccountName" . }} + namespace: {{ .Release.Namespace | quote }} + + diff --git a/examples/helm_chart/templates/configmap.yaml b/examples/helm_chart/templates/configmap.yaml new file mode 100644 index 00000000..5e68b381 --- /dev/null +++ b/examples/helm_chart/templates/configmap.yaml @@ -0,0 +1,14 @@ +{{- if .Values.configMap.create -}} +kind: ConfigMap +apiVersion: v1 +metadata: + name: {{ include "kubescape.fullname" . }}-configmap + labels: + {{- include "kubescape.labels" . | nindent 4 }} +data: + config.json: | + { + "customerGUID": {{ .Values.configMap.params.customerGUID }}, + "clusterName": {{ .Values.configMap.params.clusterName }} + } +{{- end }} diff --git a/examples/helm_chart/templates/cronjob.yaml b/examples/helm_chart/templates/cronjob.yaml new file mode 100644 index 00000000..99800e68 --- /dev/null +++ b/examples/helm_chart/templates/cronjob.yaml @@ -0,0 +1,28 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "kubescape.fullname" . }} + labels: + {{- include "kubescape.labels" . | nindent 4 }} +spec: + schedule: "{{ .Values.schedule }}" + jobTemplate: + spec: + template: + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}/{{ .Values.image.imageName }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["/bin/sh", "-c"] + args: ["kubescape scan framework nsa --submit"] + volumeMounts: + - name: kubescape-config-volume + mountPath: /root/.kubescape/config.json + subPath: config.json + restartPolicy: OnFailure + serviceAccountName: {{ include "kubescape.serviceAccountName" . }} + volumes: + - name: kubescape-config-volume + configMap: + name: {{ include "kubescape.fullname" . }}-configmap diff --git a/examples/helm_chart/templates/role.yaml b/examples/helm_chart/templates/role.yaml new file mode 100644 index 00000000..ecfc740a --- /dev/null +++ b/examples/helm_chart/templates/role.yaml @@ -0,0 +1,11 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "kubescape.fullname" . }} + labels: + {{- include "kubescape.labels" . | nindent 4 }} +rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["get", "list", "describe"] + diff --git a/examples/helm_chart/templates/rolebinding.yaml b/examples/helm_chart/templates/rolebinding.yaml new file mode 100644 index 00000000..0b392abd --- /dev/null +++ b/examples/helm_chart/templates/rolebinding.yaml @@ -0,0 +1,16 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "kubescape.fullname" . }} + labels: + {{- include "kubescape.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "kubescape.fullname" . }} +subjects: + - kind: ServiceAccount + name: {{ include "kubescape.serviceAccountName" . }} + namespace: {{ .Release.Namespace | quote }} + + diff --git a/examples/helm_chart/templates/serviceaccount.yaml b/examples/helm_chart/templates/serviceaccount.yaml new file mode 100644 index 00000000..50abf451 --- /dev/null +++ b/examples/helm_chart/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kubescape.serviceAccountName" . }} + labels: + {{- include "kubescape.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/examples/helm_chart/values.yaml b/examples/helm_chart/values.yaml new file mode 100644 index 00000000..2ff587f5 --- /dev/null +++ b/examples/helm_chart/values.yaml @@ -0,0 +1,67 @@ +# Default values for kubescape. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# -- Frequency of running the scan +schedule: "0 0 * * *" + +# -- Image and version to deploy +image: + repository: quay.io/armosec + imageName: kubescape + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: latest + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "kubescape-discovery" + +# -- ARMO customer information +configMap: + create: true + params: + customerGUID: , + clusterName: + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + + +nodeSelector: {} + +tolerations: [] + +affinity: {} From a21e9d706e0ae74dead646416cf2355c664f33db Mon Sep 17 00:00:00 2001 From: Yonah Dissen Date: Sun, 7 Nov 2021 21:23:57 +0200 Subject: [PATCH 49/70] small changes in helm chart --- examples/helm_chart/README.md | 6 ++---- examples/helm_chart/values.yaml | 21 +++++++++------------ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/examples/helm_chart/README.md b/examples/helm_chart/README.md index 92c75549..d296b88c 100644 --- a/examples/helm_chart/README.md +++ b/examples/helm_chart/README.md @@ -17,12 +17,10 @@ Kubescape is the first open-source tool for testing if Kubernetes is deployed se | nodeSelector | object | `{}` | | | podAnnotations | object | `{}` | | | podSecurityContext | object | `{}` | | -| resources | object | `{}` | | +| resources | object | `{"limits":{"cpu":"500m","memory":"512Mi"},"requests":{"cpu":"200m","memory":"256Mi"}}` | Default resources for running the service in cluster | | schedule | string | `"0 0 * * *"` | Frequency of running the scan | | securityContext | object | `{}` | | -| serviceAccount.annotations | object | `{}` | | -| serviceAccount.create | bool | `true` | | -| serviceAccount.name | string | `"kubescape-discovery"` | | +| serviceAccount | object | `{"annotations":{},"create":true,"name":"kubescape-discovery"}` | Service account that runs the scan and has permissions to view the cluster | | tolerations | list | `[]` | | ---------------------------------------------- diff --git a/examples/helm_chart/values.yaml b/examples/helm_chart/values.yaml index 2ff587f5..c81f55ee 100644 --- a/examples/helm_chart/values.yaml +++ b/examples/helm_chart/values.yaml @@ -17,6 +17,7 @@ imagePullSecrets: [] nameOverride: "" fullnameOverride: "" +# -- Service account that runs the scan and has permissions to view the cluster serviceAccount: # Specifies whether a service account should be created create: true @@ -46,18 +47,14 @@ securityContext: {} # runAsNonRoot: true # runAsUser: 1000 - -resources: {} - # We usually recommend not to specify default resources and to leave this as a conscious - # choice for the user. This also increases chances charts run on environments with little - # resources, such as Minikube. If you do want to specify resources, uncomment the following - # lines, adjust them as necessary, and remove the curly braces after 'resources:'. - # limits: - # cpu: 100m - # memory: 128Mi - # requests: - # cpu: 100m - # memory: 128Mi +# -- Default resources for running the service in cluster +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 200m + memory: 256Mi nodeSelector: {} From d1c23f74420f04749e5ef5e544e0d51d0452feab Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Mon, 8 Nov 2021 10:39:31 +0200 Subject: [PATCH 50/70] scan multiple controls --- cautils/getter/loadpolicy.go | 2 +- policyhandler/handlepullpolicies.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cautils/getter/loadpolicy.go b/cautils/getter/loadpolicy.go index dafc2201..a3c1fb9e 100644 --- a/cautils/getter/loadpolicy.go +++ b/cautils/getter/loadpolicy.go @@ -40,7 +40,7 @@ func (lp *LoadPolicy) GetControl(controlName string) (*reporthandling.Control, e return control, err } if controlName != "" && !strings.EqualFold(controlName, control.Name) && !strings.EqualFold(controlName, control.ControlID) { - framework, err := lp.GetFramework(controlName) + framework, err := lp.GetFramework(control.Name) if err != nil { return nil, fmt.Errorf("control from file not matching") } else { diff --git a/policyhandler/handlepullpolicies.go b/policyhandler/handlepullpolicies.go index 44f895ac..005fe228 100644 --- a/policyhandler/handlepullpolicies.go +++ b/policyhandler/handlepullpolicies.go @@ -59,9 +59,9 @@ func (policyHandler *PolicyHandler) getScanPolicies(notification *reporthandling if err != nil { return frameworks, policyDownloadError(err) } - } - if receivedControl != nil { - f.Controls = append(f.Controls, *receivedControl) + if receivedControl != nil { + f.Controls = append(f.Controls, *receivedControl) + } } frameworks = append(frameworks, f) // TODO: add case for control from file From 0891d64654a46fbd17dac450c85f2eec678cb6b3 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Mon, 8 Nov 2021 10:48:39 +0200 Subject: [PATCH 51/70] comment to run workflow --- clihandler/cmd/control.go | 1 + 1 file changed, 1 insertion(+) diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index 8c85297d..9264f8b2 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -44,6 +44,7 @@ var controlCmd = &cobra.Command{ } if len(args) > 1 { + // Set scan to run on yamls if err := scanInfo.SetInputPatterns(args); err != nil { return err } From 94f52fb4ac995dad7659a8957afb0c04aa47d02d Mon Sep 17 00:00:00 2001 From: Yonah Dissen Date: Mon, 8 Nov 2021 11:52:04 +0200 Subject: [PATCH 52/70] Documentation running using docker --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c2cb9045..693fb99f 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ docker build -t kubescape -f build/Dockerfile . ### Run using docker image ``` -docker run -v /full_path_to/example.yaml:/app/example.yaml quay.io/armosec/kubescape scan framework nsa /app/example.yaml +docker run -v "$(pwd)/example.yaml:/app/example.yaml quay.io/armosec/kubescape scan framework nsa /app/example.yaml ``` # Under the hood From 8f08271664dea0a3df8c4b5c4bd98926e75e03db Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 9 Nov 2021 11:16:13 +0200 Subject: [PATCH 53/70] udpate cronjob configmap --- README.md | 18 ++++++++++-------- examples/helm_chart/templates/configmap.yaml | 4 ++-- examples/helm_chart/values.yaml | 18 ++++++++++++++---- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c6ea2b99..045405ba 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ kubescape scan framework nsa --format json --output results.json kubescape scan framework nsa --format junit --output results.xml ``` -* Output in `prometheus` metrics format +* Output in `prometheus` metrics format - Contributed by [@Joibel](https://github.com/Joibel) ``` kubescape scan framework nsa --format prometheus ``` @@ -188,12 +188,6 @@ Kubescape is an open source project, we welcome your feedback and ideas for impr Use the `submit` command if you wish to submit data manually -## Submit Role-Based Access Control (RBAC) -``` -kubescape submit rbac -``` - - ## Submit scan results manually First, scan your cluster using the `json` format flag: `kubescape scan framework --format json --output path/to/results.json`. @@ -260,11 +254,19 @@ git clone https://github.com/armosec/kubescape.git kubescape && cd "$_" docker build -t kubescape -f build/Dockerfile . ``` -### Run using docker image +### Scan using docker image ``` docker run -v "$(pwd)/example.yaml:/app/example.yaml quay.io/armosec/kubescape scan framework nsa /app/example.yaml ``` +### Periodically Kubescape scanning using Helm - Contributed by [@yonahd](https://github.com/yonahd) + +You can scan your cluster periodically by adding a `CronJob` that will repeatedly trigger kubescape + +``` +helm install kubescape examples/helm_chart/ -f examples/helm_chart/values.yaml +``` + # Under the hood ## Tests diff --git a/examples/helm_chart/templates/configmap.yaml b/examples/helm_chart/templates/configmap.yaml index 5e68b381..861a8321 100644 --- a/examples/helm_chart/templates/configmap.yaml +++ b/examples/helm_chart/templates/configmap.yaml @@ -8,7 +8,7 @@ metadata: data: config.json: | { - "customerGUID": {{ .Values.configMap.params.customerGUID }}, - "clusterName": {{ .Values.configMap.params.clusterName }} + "customerGUID": "{{ .Values.configMap.params.customerGUID }}", + "clusterName": "{{ .Values.configMap.params.clusterName }}" } {{- end }} diff --git a/examples/helm_chart/values.yaml b/examples/helm_chart/values.yaml index c81f55ee..883c4bb7 100644 --- a/examples/helm_chart/values.yaml +++ b/examples/helm_chart/values.yaml @@ -3,13 +3,23 @@ # Declare variables to be passed into your templates. # -- Frequency of running the scan -schedule: "0 0 * * *" +# ┌────────────── timezone (optional) +# | ┌───────────── minute (0 - 59) +# | │ ┌───────────── hour (0 - 23) +# | │ │ ┌───────────── day of the month (1 - 31) +# | │ │ │ ┌───────────── month (1 - 12) +# | │ │ │ │ ┌───────────── day of the week (0 - 6) (Sunday to Saturday; +# | │ │ │ │ │ 7 is also Sunday on some systems) +# | │ │ │ │ │ +# | │ │ │ │ │ +# UTC * * * * * +schedule: "* * 1 * *" # -- Image and version to deploy image: repository: quay.io/armosec imageName: kubescape - pullPolicy: IfNotPresent + pullPolicy: Always # Overrides the image tag whose default is the chart appVersion. tag: latest @@ -29,9 +39,9 @@ serviceAccount: # -- ARMO customer information configMap: - create: true + create: false params: - customerGUID: , + customerGUID: clusterName: podAnnotations: {} From eafece649742a121e575e610f81934db3970699a Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 9 Nov 2021 11:18:30 +0200 Subject: [PATCH 54/70] update helm command in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 045405ba..1c36dd00 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ docker run -v "$(pwd)/example.yaml:/app/example.yaml quay.io/armosec/kubescape You can scan your cluster periodically by adding a `CronJob` that will repeatedly trigger kubescape ``` -helm install kubescape examples/helm_chart/ -f examples/helm_chart/values.yaml +helm install kubescape examples/helm_chart/ ``` # Under the hood From a37d00b40a9cf1d163abeb6b55e99397f39238ca Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 9 Nov 2021 17:07:06 +0200 Subject: [PATCH 55/70] checking latest version --- build.py | 2 +- cautils/getter/getpoliciesutils.go | 22 +++- cautils/versioncheck.go | 112 ++++++++++++++++++ clihandler/cmd/version.go | 33 +----- clihandler/initcli.go | 6 + main.go | 20 ---- .../reporter/reporteventreceiver.go | 16 +-- .../reporter/reporteventreceiverutils.go | 25 ---- 8 files changed, 147 insertions(+), 89 deletions(-) create mode 100644 cautils/versioncheck.go diff --git a/build.py b/build.py index 685604bb..ec8a5497 100644 --- a/build.py +++ b/build.py @@ -41,7 +41,7 @@ def main(): # Set some variables packageName = getPackageName() - buildUrl = "github.com/armosec/kubescape/clihandler/cmd.BuildNumber" + buildUrl = "github.com/armosec/kubescape/cautils.BuildNumber" releaseVersion = os.getenv("RELEASE") ArmoBEServer = os.getenv("ArmoBEServer") ArmoERServer = os.getenv("ArmoERServer") diff --git a/cautils/getter/getpoliciesutils.go b/cautils/getter/getpoliciesutils.go index 7949eb70..a8b4a271 100644 --- a/cautils/getter/getpoliciesutils.go +++ b/cautils/getter/getpoliciesutils.go @@ -1,6 +1,7 @@ package getter import ( + "bytes" "encoding/json" "fmt" "io" @@ -87,7 +88,24 @@ func HttpGetter(httpClient *http.Client, fullURL string) (string, error) { if err != nil { return "", err } - respStr, err := httpRespToString(resp) + respStr, err := HttpRespToString(resp) + if err != nil { + return "", err + } + return respStr, nil +} + +func HttpPost(httpClient *http.Client, fullURL string, body []byte) (string, error) { + + req, err := http.NewRequest("POST", fullURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + respStr, err := HttpRespToString(resp) if err != nil { return "", err } @@ -95,7 +113,7 @@ func HttpGetter(httpClient *http.Client, fullURL string) (string, error) { } // HTTPRespToString parses the body as string and checks the HTTP status code, it closes the body reader at the end -func httpRespToString(resp *http.Response) (string, error) { +func HttpRespToString(resp *http.Response) (string, error) { if resp == nil || resp.Body == nil { return "", nil } diff --git a/cautils/versioncheck.go b/cautils/versioncheck.go new file mode 100644 index 00000000..c8bdcbc1 --- /dev/null +++ b/cautils/versioncheck.go @@ -0,0 +1,112 @@ +package cautils + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/armosec/kubescape/cautils/getter" + pkgutils "github.com/armosec/utils-go/utils" +) + +const SKIP_VERSION_CHECK = "KUBESCAPE_SKIP_UPDATE_CHECK" + +var BuildNumber string + +type IVersionCheckHandler interface { + CheckLatestVersion(*VersionCheckRequest) error +} + +func NewIVersionCheckHandler() IVersionCheckHandler { + if v, ok := os.LookupEnv(SKIP_VERSION_CHECK); ok && pkgutils.StringToBool(v) { + return NewVersionCheckHandlerMock() + } + return NewVersionCheckHandler() +} + +type VersionCheckHandlerMock struct { +} + +func NewVersionCheckHandlerMock() *VersionCheckHandlerMock { + return &VersionCheckHandlerMock{} +} + +type VersionCheckHandler struct { + versionURL string +} +type VersionCheckRequest struct { + Client string `json:"client"` // kubescape + ClientVersion string `json:"clientVersion"` // kubescape version + Framework string `json:"framework"` // framework name + FrameworkVersion string `json:"frameworkVersion"` // framework version + ScanningTarget string `json:"target"` // scanning target- cluster/yaml +} + +type VersionCheckResponse struct { + Client string `json:"client"` // kubescape + ClientUpdate string `json:"clientUpdate"` // kubescape latest version + Framework string `json:"framework"` // framework name + FrameworkUpdate string `json:"frameworkUpdate"` // framework latest version + Message string `json:"message"` // alert message +} + +func NewVersionCheckHandler() *VersionCheckHandler { + return &VersionCheckHandler{ + versionURL: "https://us-central1-elated-pottery-310110.cloudfunctions.net/ksgf1v1", + } +} +func NewVersionCheckRequest(buildNumber, frameworkName, frameworkVersion, scanningTarget string) *VersionCheckRequest { + return &VersionCheckRequest{ + Client: "kubescape", + ClientVersion: buildNumber, + Framework: frameworkName, + FrameworkVersion: frameworkVersion, + ScanningTarget: scanningTarget, + } +} + +func (v *VersionCheckHandlerMock) CheckLatestVersion(versionData *VersionCheckRequest) error { + fmt.Println("Skipping version check") + return nil +} + +func (v *VersionCheckHandler) CheckLatestVersion(versionData *VersionCheckRequest) error { + + latestVersion, err := v.getLatestVersion(versionData) + if err != nil || latestVersion == nil { + return fmt.Errorf("failed to get latest version: %v", err) + } + + if latestVersion.ClientUpdate != "" { + fmt.Println(warningMessage(latestVersion.Client, latestVersion.ClientUpdate)) + } + + if latestVersion.FrameworkUpdate != "" { + fmt.Println(warningMessage(latestVersion.Framework, latestVersion.FrameworkUpdate)) + } + return nil +} + +func (v *VersionCheckHandler) getLatestVersion(versionData *VersionCheckRequest) (*VersionCheckResponse, error) { + + reqBody, err := json.Marshal(versionData) + if err != nil { + return nil, fmt.Errorf("in 'CheckLatestVersion' failed to json.Marshal, reason: %v", err) + } + + resp, err := getter.HttpPost(http.DefaultClient, v.versionURL, reqBody) + if err != nil { + return nil, err + } + + vResp := &VersionCheckResponse{} + if err = getter.JSONDecoder(resp).Decode(vResp); err != nil { + return nil, err + } + return vResp, nil +} + +func warningMessage(kind, release string) string { + return fmt.Sprintf("Warning: '%s' is not updated to the latest release: '%s'", kind, release) +} diff --git a/clihandler/cmd/version.go b/clihandler/cmd/version.go index f4623813..aa47b0cb 100644 --- a/clihandler/cmd/version.go +++ b/clihandler/cmd/version.go @@ -1,49 +1,24 @@ package cmd import ( - "encoding/json" "fmt" - "io" - "net/http" + "github.com/armosec/kubescape/cautils" "github.com/spf13/cobra" ) -var BuildNumber string - var versionCmd = &cobra.Command{ Use: "version", Short: "Get current version", Long: ``, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("Your current version is: " + BuildNumber) + v := cautils.NewIVersionCheckHandler() + v.CheckLatestVersion(cautils.NewVersionCheckRequest(cautils.BuildNumber, "", "", "")) + fmt.Println("Your current version is: " + cautils.BuildNumber) return nil }, } -func GetLatestVersion() (string, error) { - latestVersion := "https://api.github.com/repos/armosec/kubescape/releases/latest" - resp, err := http.Get(latestVersion) - if err != nil { - return "unknown", fmt.Errorf("failed to get latest releases from '%s', reason: %s", latestVersion, err.Error()) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || 301 < resp.StatusCode { - return "unknown", nil - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "unknown", fmt.Errorf("failed to read response body from '%s', reason: %s", latestVersion, err.Error()) - } - var data map[string]interface{} - err = json.Unmarshal(body, &data) - if err != nil { - return "unknown", fmt.Errorf("failed to unmarshal response body from '%s', reason: %s", latestVersion, err.Error()) - } - return fmt.Sprintf("%v", data["tag_name"]), nil -} - func init() { rootCmd.AddCommand(versionCmd) } diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 153eeeb1..bcea8aa3 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -54,6 +54,7 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { var resourceHandler resourcehandler.IResourceHandler var clusterConfig cautils.IClusterConfig var reportHandler reporter.IReport + var scanningTarget string if !scanInfo.ScanRunningCluster() { k8sinterface.ConnectedToCluster = false @@ -64,6 +65,7 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { // set mock report (do not send report) reportHandler = reporter.NewReportMock() + scanningTarget = "yaml" } else { k8s := k8sinterface.NewKubernetesApi() resourceHandler = resourcehandler.NewK8sResourceHandler(k8s, getFieldSelector(scanInfo)) @@ -71,8 +73,12 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { // setup reporter reportHandler = getReporter(scanInfo) + scanningTarget = "cluster" } + v := cautils.NewIVersionCheckHandler() + v.CheckLatestVersion(cautils.NewVersionCheckRequest(cautils.BuildNumber, "", "", scanningTarget)) + // setup printer printerHandler := printer.GetPrinter(scanInfo.Format) printerHandler.SetWriter(scanInfo.Output) diff --git a/main.go b/main.go index 84329f92..07bf089e 100644 --- a/main.go +++ b/main.go @@ -1,29 +1,9 @@ package main import ( - "fmt" - "os" - "github.com/armosec/kubescape/clihandler/cmd" - pkgutils "github.com/armosec/utils-go/utils" ) -const SKIP_VERSION_CHECK = "KUBESCAPE_SKIP_UPDATE_CHECK" - func main() { - CheckLatestVersion() cmd.Execute() } - -func CheckLatestVersion() { - if v, ok := os.LookupEnv(SKIP_VERSION_CHECK); ok && pkgutils.StringToBool(v) { - return - } - latest, err := cmd.GetLatestVersion() - if err != nil || latest == "unknown" { - return - } - if latest != cmd.BuildNumber { - fmt.Println("Warning: You are not updated to the latest release: " + latest) - } -} diff --git a/resultshandling/reporter/reporteventreceiver.go b/resultshandling/reporter/reporteventreceiver.go index 6ce5a08d..83e4d033 100644 --- a/resultshandling/reporter/reporteventreceiver.go +++ b/resultshandling/reporter/reporteventreceiver.go @@ -1,12 +1,12 @@ package reporter import ( - "bytes" "encoding/json" "fmt" "net/http" "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/cautils/getter" "github.com/armosec/opa-utils/reporthandling" ) @@ -17,14 +17,14 @@ type IReport interface { } type ReportEventReceiver struct { - httpClient http.Client + httpClient *http.Client clusterName string customerGUID string } func NewReportEventReceiver(customerGUID, clusterName string) *ReportEventReceiver { return &ReportEventReceiver{ - httpClient: http.Client{}, + httpClient: &http.Client{}, clusterName: clusterName, customerGUID: customerGUID, } @@ -58,15 +58,7 @@ func (report *ReportEventReceiver) send(postureReport *reporthandling.PostureRep } host := hostToString(report.initEventReceiverURL(), postureReport.ReportID) - req, err := http.NewRequest("POST", host, bytes.NewReader(reqBody)) - if err != nil { - return fmt.Errorf("in 'Send', http.NewRequest failed, host: %s, reason: %v", host, err) - } - res, err := report.httpClient.Do(req) - if err != nil { - return fmt.Errorf("httpClient.Do failed: %v", err) - } - msg, err := httpRespToString(res) + msg, err := getter.HttpPost(report.httpClient, host, reqBody) if err != nil { return fmt.Errorf("%s, %v:%s", host, err, msg) } diff --git a/resultshandling/reporter/reporteventreceiverutils.go b/resultshandling/reporter/reporteventreceiverutils.go index a32a3c22..0f90dbae 100644 --- a/resultshandling/reporter/reporteventreceiverutils.go +++ b/resultshandling/reporter/reporteventreceiverutils.go @@ -1,37 +1,12 @@ package reporter import ( - "fmt" - "io" - "net/http" "net/url" - "strings" "github.com/armosec/kubescape/cautils/getter" "github.com/gofrs/uuid" ) -// HTTPRespToString parses the body as string and checks the HTTP status code, it closes the body reader at the end -func httpRespToString(resp *http.Response) (string, error) { - if resp == nil || resp.Body == nil { - return "", nil - } - strBuilder := strings.Builder{} - defer resp.Body.Close() - if resp.ContentLength > 0 { - strBuilder.Grow(int(resp.ContentLength)) - } - _, err := io.Copy(&strBuilder, resp.Body) - if err != nil { - return strBuilder.String(), err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - err = fmt.Errorf("response status: %d. Content: %s", resp.StatusCode, strBuilder.String()) - } - - return strBuilder.String(), err -} - func (report *ReportEventReceiver) initEventReceiverURL() *url.URL { urlObj := url.URL{} From 5a81a77d9291875d18ed085cf8dec0108e9f86d2 Mon Sep 17 00:00:00 2001 From: dwertent Date: Wed, 10 Nov 2021 07:53:35 +0200 Subject: [PATCH 56/70] adding json to http headers --- cautils/getter/armoapi.go | 8 +++---- cautils/getter/getpoliciesutils.go | 21 ++++++++++++++----- cautils/versioncheck.go | 2 +- resourcehandler/repositoryscanner.go | 4 ++-- .../reporter/reporteventreceiver.go | 2 +- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/cautils/getter/armoapi.go b/cautils/getter/armoapi.go index 88f50b2b..16dbf893 100644 --- a/cautils/getter/armoapi.go +++ b/cautils/getter/armoapi.go @@ -92,7 +92,7 @@ func (armoAPI *ArmoAPI) GetReportReceiverURL() string { } func (armoAPI *ArmoAPI) GetFramework(name string) (*reporthandling.Framework, error) { - respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getFrameworkURL(name)) + respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getFrameworkURL(name), nil) if err != nil { return nil, err } @@ -111,7 +111,7 @@ func (armoAPI *ArmoAPI) GetExceptions(customerGUID, clusterName string) ([]armot if customerGUID == "" { return exceptions, nil } - respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getExceptionsURL(customerGUID, clusterName)) + respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getExceptionsURL(customerGUID, clusterName), nil) if err != nil { return nil, err } @@ -128,7 +128,7 @@ func (armoAPI *ArmoAPI) GetCustomerGUID(customerGUID string) (*TenantResponse, e if customerGUID != "" { url = fmt.Sprintf("%s?customerGUID=%s", url, customerGUID) } - respStr, err := HttpGetter(armoAPI.httpClient, url) + respStr, err := HttpGetter(armoAPI.httpClient, url, nil) if err != nil { return nil, err } @@ -146,7 +146,7 @@ func (armoAPI *ArmoAPI) GetAccountConfig(customerGUID, clusterName string) (*arm if customerGUID == "" { return accountConfig, nil } - respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getAccountConfig(customerGUID, clusterName)) + respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getAccountConfig(customerGUID, clusterName), nil) if err != nil { return nil, err } diff --git a/cautils/getter/getpoliciesutils.go b/cautils/getter/getpoliciesutils.go index a8b4a271..a20f2b1d 100644 --- a/cautils/getter/getpoliciesutils.go +++ b/cautils/getter/getpoliciesutils.go @@ -78,42 +78,53 @@ func JSONDecoder(origin string) *json.Decoder { return dec } -func HttpGetter(httpClient *http.Client, fullURL string) (string, error) { +func HttpGetter(httpClient *http.Client, fullURL string, headers map[string]string) (string, error) { req, err := http.NewRequest("GET", fullURL, nil) if err != nil { return "", err } + addHeaders(req, headers) + resp, err := httpClient.Do(req) if err != nil { return "", err } - respStr, err := HttpRespToString(resp) + respStr, err := httpRespToString(resp) if err != nil { return "", err } return respStr, nil } -func HttpPost(httpClient *http.Client, fullURL string, body []byte) (string, error) { +func HttpPost(httpClient *http.Client, fullURL string, headers map[string]string, body []byte) (string, error) { req, err := http.NewRequest("POST", fullURL, bytes.NewReader(body)) if err != nil { return "", err } + addHeaders(req, headers) resp, err := httpClient.Do(req) if err != nil { return "", err } - respStr, err := HttpRespToString(resp) + respStr, err := httpRespToString(resp) if err != nil { return "", err } return respStr, nil } +func addHeaders(req *http.Request, headers map[string]string) { + if len(headers) >= 0 { // might be nil + for k, v := range headers { + req.Header.Add(k, v) + } + } +} + // HTTPRespToString parses the body as string and checks the HTTP status code, it closes the body reader at the end -func HttpRespToString(resp *http.Response) (string, error) { +func httpRespToString(resp *http.Response) (string, error) { if resp == nil || resp.Body == nil { return "", nil } diff --git a/cautils/versioncheck.go b/cautils/versioncheck.go index c8bdcbc1..8483f07e 100644 --- a/cautils/versioncheck.go +++ b/cautils/versioncheck.go @@ -95,7 +95,7 @@ func (v *VersionCheckHandler) getLatestVersion(versionData *VersionCheckRequest) return nil, fmt.Errorf("in 'CheckLatestVersion' failed to json.Marshal, reason: %v", err) } - resp, err := getter.HttpPost(http.DefaultClient, v.versionURL, reqBody) + resp, err := getter.HttpPost(http.DefaultClient, v.versionURL, map[string]string{"Content-Type": "application/json"}, reqBody) if err != nil { return nil, err } diff --git a/resourcehandler/repositoryscanner.go b/resourcehandler/repositoryscanner.go index 7b44017d..f4f7901c 100644 --- a/resourcehandler/repositoryscanner.go +++ b/resourcehandler/repositoryscanner.go @@ -95,7 +95,7 @@ func (g *GitHubRepository) setBranch(branchOptional string) error { // By default it is "master", unless the branchOptional came with a value if branchOptional == "" { - body, err := getter.HttpGetter(&http.Client{}, g.defaultBranchAPI()) + body, err := getter.HttpGetter(&http.Client{}, g.defaultBranchAPI(), nil) if err != nil { return err } @@ -117,7 +117,7 @@ func (g *GitHubRepository) defaultBranchAPI() string { } func (g *GitHubRepository) setTree() error { - body, err := getter.HttpGetter(&http.Client{}, g.treeAPI()) + body, err := getter.HttpGetter(&http.Client{}, g.treeAPI(), nil) if err != nil { return err } diff --git a/resultshandling/reporter/reporteventreceiver.go b/resultshandling/reporter/reporteventreceiver.go index 83e4d033..2bd98392 100644 --- a/resultshandling/reporter/reporteventreceiver.go +++ b/resultshandling/reporter/reporteventreceiver.go @@ -58,7 +58,7 @@ func (report *ReportEventReceiver) send(postureReport *reporthandling.PostureRep } host := hostToString(report.initEventReceiverURL(), postureReport.ReportID) - msg, err := getter.HttpPost(report.httpClient, host, reqBody) + msg, err := getter.HttpPost(report.httpClient, host, nil, reqBody) if err != nil { return fmt.Errorf("%s, %v:%s", host, err, msg) } From cb34d17ba155378e1b0a79d4734f598419b51bfc Mon Sep 17 00:00:00 2001 From: dwertent Date: Wed, 10 Nov 2021 08:01:33 +0200 Subject: [PATCH 57/70] fixed merge --- README.md | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 30814d7d..be61cb39 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,11 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser ## Flags -<<<<<<< HEAD | flag | default | description | options | |-----------------------------|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------| | `-e`/`--exclude-namespaces` | Scan all namespaces | Namespaces to exclude from scanning. Recommended to exclude `kube-system` and `kube-public` namespaces | | | `-s`/`--silent` | Display progress messages | Silent progress messages | | -| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result bellow threshold | `0` -> `100` | +| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result is below threshold | `0` -> `100` | | `-f`/`--format` | `pretty-printer` | Output format | `pretty-printer`/`json`/`junit`/`prometheus` | | `-o`/`--output` | print to stdout | Save scan result in file | | | `--use-from` | | Load local framework object from specified path. If not used will download latest | | @@ -83,21 +82,7 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser | `--submit` | `false` | If set, Kubescape will send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not sent | `true`/`false` | | `--keep-local` | `false` | Kubescape will not send scan results to Armo management portal. Use this flag if you ran with the `--submit` flag in the past and you do not want to submit your current scan results | `true`/`false` | | `--account` | | Armo portal account ID. Default will load account ID from configMap or config file | | -======= -| flag | default | description | options | -| --- | --- | --- | --- | -| `-e`/`--exclude-namespaces` | Scan all namespaces | Namespaces to exclude from scanning. Recommended to exclude `kube-system` and `kube-public` namespaces | -| `-s`/`--silent` | Display progress messages | Silent progress messages | -| `-t`/`--fail-threshold` | `0` (do not fail) | fail command (return exit code 1) if result is below threshold| `0` -> `100` | -| `-f`/`--format` | `pretty-printer` | Output format | `pretty-printer`/`json`/`junit` | -| `-o`/`--output` | print to stdout | Save scan result in file | -| `--use-from` | | Load local framework object from specified path. If not used will download latest | -| `--use-default` | `false` | Load local framework object from default path. If not used will download latest | `true`/`false` | -| `--exceptions` | | Path to an [exceptions obj](examples/exceptions.json). If not set will download exceptions from Armo management portal | -| `--submit` | `false` | If set, Kubescape will send the scan results to Armo management portal where you can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more. By default the results are not sent | `true`/`false`| -| `--keep-local` | `false` | Kubescape will not send scan results to Armo management portal. Use this flag if you ran with the `--submit` flag in the past and you do not want to submit your current scan results | `true`/`false`| -| `--account` | | Armo portal account ID. Default will load account ID from configMap or config file | | ->>>>>>> 0168b768d26c74b2f55565067c3a12bc8d259689 + ## Usage & Examples From 8f791ceb120985738652bda198156ad02973e81a Mon Sep 17 00:00:00 2001 From: dwertent Date: Wed, 10 Nov 2021 09:38:34 +0200 Subject: [PATCH 58/70] Improve readme --- README.md | 79 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 8c3fbfc7..0e12d043 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,17 @@ kubescape scan framework nsa +
+ +> Kubescape is an open source project, we welcome your feedback and ideas for improvement. We’re also aiming to collaborate with the Kubernetes community to help make the tests themselves more robust and complete as Kubernetes develops. + +
+ ### Click [👍](https://github.com/armosec/kubescape/stargazers) if you want us to continue to develop and improve Kubescape 😀 +
+ + # Being part of the team We invite you to our team! We are excited about this project and want to return the love we get. @@ -42,6 +51,7 @@ Want to contribute? Want to discuss something? Have an issue? [logo](https://armosec.github.io/kubescape/) + # Options and examples ## Tutorials @@ -95,78 +105,74 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser ### Examples -* Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/News-Features/Feature-Stories/Article-View/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) +#### Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/News-Features/Feature-Stories/Article-View/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) ``` kubescape scan framework nsa --submit ``` -* Scan a running Kubernetes cluster with [`MITRE ATT&CK®`](https://www.microsoft.com/security/blog/2021/03/23/secure-containerized-environments-with-updated-threat-matrix-for-kubernetes/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) +#### Scan a running Kubernetes cluster with [`MITRE ATT&CK®`](https://www.microsoft.com/security/blog/2021/03/23/secure-containerized-environments-with-updated-threat-matrix-for-kubernetes/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) ``` kubescape scan framework mitre --submit ``` -* Scan a running Kubernetes cluster with a specific control using the control name or control ID. [List of controls](https://hub.armo.cloud/docs/controls) +#### Scan a running Kubernetes cluster with a specific control using the control name or control ID. [List of controls](https://hub.armo.cloud/docs/controls) ``` kubescape scan control "Privileged container" ``` -* Scan specific namespaces +#### Scan specific namespaces ``` kubescape scan framework nsa --include-namespaces development,staging,production ``` -* Scan cluster and exclude some namespaces +#### Scan cluster and exclude some namespaces ``` kubescape scan framework nsa --exclude-namespaces kube-system,kube-public ``` -* Scan local `yaml`/`json` files before deploying. [Take a look at the demonstration](https://youtu.be/Ox6DaR7_4ZI) +#### Scan local `yaml`/`json` files before deploying. [Take a look at the demonstration](https://youtu.be/Ox6DaR7_4ZI) ``` kubescape scan framework nsa *.yaml ``` -* Scan kubernetes manifest files from a public github repository +#### Scan kubernetes manifest files from a public github repository ``` kubescape scan framework nsa https://github.com/armosec/kubescape ``` -* Output in `json` format +#### Output in `json` format ``` kubescape scan framework nsa --format json --output results.json ``` -* Output in `junit xml` format +#### Output in `junit xml` format ``` kubescape scan framework nsa --format junit --output results.xml ``` -* Output in `prometheus` metrics format - Contributed by [@Joibel](https://github.com/Joibel) +#### Output in `prometheus` metrics format - Contributed by [@Joibel](https://github.com/Joibel) ``` kubescape scan framework nsa --format prometheus ``` -* Scan with exceptions, objects with exceptions will be presented as `exclude` and not `fail` +#### Scan with exceptions, objects with exceptions will be presented as `exclude` and not `fail` ``` kubescape scan framework nsa --exceptions examples/exceptions.json ``` -### CronJob Scan Periodically - -For setting up a cronJob please follow the [instructions](examples/cronJob-support/README.md) - -### Helm Support - -* Render the helm chart using [`helm template`](https://helm.sh/docs/helm/helm_template/) and pass to stdout +#### Scan Helm charts - Render the helm chart using [`helm template`](https://helm.sh/docs/helm/helm_template/) and pass to stdout ``` helm template [NAME] [CHART] [flags] --dry-run | kubescape scan framework nsa - ``` -for example: +e.g. ``` helm template bitnami/mysql --generate-name --dry-run | kubescape scan framework nsa - ``` + + ### Offline Support It is possible to run Kubescape offline! @@ -183,7 +189,22 @@ kubescape download framework nsa --output nsa.json kubescape scan framework nsa --use-from nsa.json ``` -Kubescape is an open source project, we welcome your feedback and ideas for improvement. We’re also aiming to collaborate with the Kubernetes community to help make the tests themselves more robust and complete as Kubernetes develops. + +## Scan Periodically using Helm - Contributed by [@yonahd](https://github.com/yonahd) + +You can scan your cluster periodically by adding a `CronJob` that will repeatedly trigger kubescape + +``` +helm install kubescape examples/helm_chart/ +``` + +## Scan using docker image + +Official Docker image `quay.io/armosec/kubescape` + +``` +docker run -v "$(pwd)/example.yaml:/app/example.yaml quay.io/armosec/kubescape scan framework nsa /app/example.yaml +``` # Submit data manually @@ -237,12 +258,8 @@ go build -o kubescape . 4. Enjoy :zany_face: -## Docker Support +## Docker Build -### Official Docker image -``` -quay.io/armosec/kubescape -``` ### Build your own Docker image 1. Clone Project @@ -255,18 +272,6 @@ git clone https://github.com/armosec/kubescape.git kubescape && cd "$_" docker build -t kubescape -f build/Dockerfile . ``` -### Scan using docker image -``` -docker run -v "$(pwd)/example.yaml:/app/example.yaml quay.io/armosec/kubescape scan framework nsa /app/example.yaml -``` - -### Periodically Kubescape scanning using Helm - Contributed by [@yonahd](https://github.com/yonahd) - -You can scan your cluster periodically by adding a `CronJob` that will repeatedly trigger kubescape - -``` -helm install kubescape examples/helm_chart/ -``` # Under the hood From 1fb642c777e3e145aa682596371c2e633165493a Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Wed, 10 Nov 2021 18:30:03 +0200 Subject: [PATCH 59/70] scan with custom framework --- cautils/customerloader.go | 3 +- cautils/getter/armoapi.go | 30 +++++++++++++--- cautils/getter/armoapiutils.go | 45 +++++++++++++++++++++--- cautils/getter/downloadreleasedpolicy.go | 16 +++++++-- cautils/scaninfo.go | 10 +----- clihandler/cmd/cluster_get.go | 3 +- clihandler/cmd/control.go | 5 +-- clihandler/cmd/download.go | 3 +- clihandler/cmd/framework.go | 33 +++++------------ clihandler/cmd/scan.go | 8 +++-- clihandler/initcli.go | 38 +++++++++++++++++--- 11 files changed, 137 insertions(+), 57 deletions(-) diff --git a/cautils/customerloader.go b/cautils/customerloader.go index dce4a9db..872aaf64 100644 --- a/cautils/customerloader.go +++ b/cautils/customerloader.go @@ -8,10 +8,10 @@ import ( "os" "strings" - "github.com/armosec/kubescape/cautils/getter" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/armosec/k8s-interface/k8sinterface" + "github.com/armosec/kubescape/cautils/getter" corev1 "k8s.io/api/core/v1" ) @@ -154,6 +154,7 @@ func NewClusterConfig(k8s *k8sinterface.KubernetesApi, backendAPI getter.IBacken defaultNS: k8sinterface.GetDefaultNamespace(), } } + func (c *ClusterConfig) GetConfigObj() *ConfigObj { return c.configObj } func (c *ClusterConfig) GetK8sAPI() *k8sinterface.KubernetesApi { return c.k8s } func (c *ClusterConfig) GetDefaultNS() string { return c.defaultNS } diff --git a/cautils/getter/armoapi.go b/cautils/getter/armoapi.go index 16dbf893..b8649bc8 100644 --- a/cautils/getter/armoapi.go +++ b/cautils/getter/armoapi.go @@ -3,9 +3,11 @@ package getter import ( "fmt" "net/http" + "strings" "time" "github.com/armosec/armoapi-go/armotypes" + "github.com/armosec/opa-utils/gitregostore" "github.com/armosec/opa-utils/reporthandling" "github.com/golang/glog" ) @@ -28,10 +30,12 @@ var ( // Armo API for downloading policies type ArmoAPI struct { - httpClient *http.Client - apiURL string - erURL string - feURL string + httpClient *http.Client + apiURL string + erURL string + feURL string + customerGUID string + gs *gitregostore.GitRegoStore } var globalArmoAPIConnecctor *ArmoAPI @@ -80,9 +84,13 @@ func NewARMOAPICustomized(armoERURL, armoBEURL, armoFEURL string) *ArmoAPI { func newArmoAPI() *ArmoAPI { return &ArmoAPI{ httpClient: &http.Client{Timeout: time.Duration(61) * time.Second}, + gs: gitregostore.InitDefaultGitRegoStore(-1), } } +func (armoAPI *ArmoAPI) SetCustomerGUID(customerGUID string) { + armoAPI.customerGUID = customerGUID +} func (armoAPI *ArmoAPI) GetFrontendURL() string { return armoAPI.feURL } @@ -106,6 +114,20 @@ func (armoAPI *ArmoAPI) GetFramework(name string) (*reporthandling.Framework, er return framework, err } +func (armoAPI *ArmoAPI) GetControl(policyName string) (*reporthandling.Control, error) { + var control *reporthandling.Control + var err error + if strings.HasPrefix(policyName, "C-") || strings.HasPrefix(policyName, "c-") { + control, err = armoAPI.gs.GetOPAControlByID(policyName) + } else { + control, err = armoAPI.gs.GetOPAControlByName(policyName) + } + if err != nil { + return nil, err + } + return control, nil +} + func (armoAPI *ArmoAPI) GetExceptions(customerGUID, clusterName string) ([]armotypes.PostureExceptionPolicy, error) { exceptions := []armotypes.PostureExceptionPolicy{} if customerGUID == "" { diff --git a/cautils/getter/armoapiutils.go b/cautils/getter/armoapiutils.go index 5699ed25..98e76bd4 100644 --- a/cautils/getter/armoapiutils.go +++ b/cautils/getter/armoapiutils.go @@ -1,24 +1,61 @@ package getter import ( + "encoding/json" "net/url" "strings" + + "github.com/armosec/opa-utils/reporthandling" ) +var NativeFrameworks = []string{"nsa", "mitre", "armobest"} + func (armoAPI *ArmoAPI) getFrameworkURL(frameworkName string) string { u := url.URL{} u.Scheme = "https" u.Host = armoAPI.apiURL - u.Path = "v1/armoFrameworks" + u.Path = "api/v1/armoFrameworks" q := u.Query() - q.Add("customerGUID", "11111111-1111-1111-1111-111111111111") - q.Add("frameworkName", strings.ToUpper(frameworkName)) - q.Add("getRules", "true") + q.Add("customerGUID", armoAPI.customerGUID) + if isNativeFramework(frameworkName) { + q.Add("frameworkName", strings.ToUpper(frameworkName)) + } else { + // For customer framework has to be the way it was added + q.Add("frameworkName", frameworkName) + } u.RawQuery = q.Encode() return u.String() } +func (armoAPI *ArmoAPI) getListFrameworkURL() string { + u := url.URL{} + u.Scheme = "https" + u.Host = armoAPI.apiURL + u.Path = "api/v1/armoFrameworks" + q := u.Query() + q.Add("customerGUID", armoAPI.customerGUID) + u.RawQuery = q.Encode() + + return u.String() +} + +func (armoAPI *ArmoAPI) GetCustomFrameworksForCustomer(customerGUID string) ([]string, error) { + respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getListFrameworkURL(), nil) + if err != nil { + return nil, err + } + frs := []reporthandling.Framework{} + frameworkList := []string{} + err = json.Unmarshal([]byte(respStr), &frs) + + for _, fr := range frs { + frameworkList = append(frameworkList, fr.Name) + } + + return frameworkList, nil +} + func (armoAPI *ArmoAPI) getExceptionsURL(customerGUID, clusterName string) string { u := url.URL{} u.Scheme = "https" diff --git a/cautils/getter/downloadreleasedpolicy.go b/cautils/getter/downloadreleasedpolicy.go index 23ca29e5..20fbbbc4 100644 --- a/cautils/getter/downloadreleasedpolicy.go +++ b/cautils/getter/downloadreleasedpolicy.go @@ -11,7 +11,7 @@ import ( // ======================================== DownloadReleasedPolicy ======================================================= // ======================================================================================================================= -// Download released version +// Use gitregostore to get policies from github release type DownloadReleasedPolicy struct { gs *gitregostore.GitRegoStore } @@ -22,7 +22,6 @@ func NewDownloadReleasedPolicy() *DownloadReleasedPolicy { } } -// Return control per name/id using ARMO api func (drp *DownloadReleasedPolicy) GetControl(policyName string) (*reporthandling.Control, error) { var control *reporthandling.Control var err error @@ -44,3 +43,16 @@ func (drp *DownloadReleasedPolicy) GetFramework(name string) (*reporthandling.Fr } return framework, err } + +func isNativeFramework(framework string) bool { + return contains(NativeFrameworks, framework) +} + +func contains(s []string, str string) bool { + for _, v := range s { + if strings.EqualFold(v, str) { + return true + } + } + return false +} diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index 041aef05..26aa806e 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -27,6 +27,7 @@ type ScanInfo struct { Local bool // Do not submit results Account string // account ID FrameworkScan bool // false if scanning control + ScanAll bool // true if scan all frameworks } type Getters struct { @@ -40,7 +41,6 @@ func (scanInfo *ScanInfo) Init() { scanInfo.setUseExceptions() scanInfo.setAccountConfig() scanInfo.setOutputFile() - scanInfo.setGetter() } @@ -86,14 +86,6 @@ func (scanInfo *ScanInfo) SetInputPatterns(args []string) error { } return nil } -func (scanInfo *ScanInfo) setGetter() { - if len(scanInfo.UseFrom) > 0 { - // load from file - scanInfo.PolicyGetter = getter.NewLoadPolicy(scanInfo.UseFrom) - } else { - scanInfo.PolicyGetter = getter.NewDownloadReleasedPolicy() - } -} func (scanInfo *ScanInfo) setOutputFile() { if scanInfo.Output == "" { diff --git a/clihandler/cmd/cluster_get.go b/clihandler/cmd/cluster_get.go index 0c0740df..e74f8db7 100644 --- a/clihandler/cmd/cluster_get.go +++ b/clihandler/cmd/cluster_get.go @@ -7,7 +7,6 @@ import ( "github.com/armosec/k8s-interface/k8sinterface" "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/cautils/getter" - "github.com/armosec/kubescape/clihandler" "github.com/spf13/cobra" ) @@ -15,7 +14,7 @@ var getCmd = &cobra.Command{ Use: "get ", Short: "Get configuration in cluster", Long: ``, - ValidArgs: clihandler.SupportedFrameworks, + ValidArgs: getter.NativeFrameworks, Args: func(cmd *cobra.Command, args []string) error { if len(args) < 1 || len(args) > 1 { return fmt.Errorf("requires one argument") diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index 9264f8b2..6e8d4a9a 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/cautils/getter" "github.com/armosec/kubescape/clihandler" "github.com/armosec/opa-utils/reporthandling" "github.com/spf13/cobra" @@ -14,7 +15,7 @@ import ( // controlCmd represents the control command var controlCmd = &cobra.Command{ Use: "control /.\nExamples:\n$ kubescape scan control C-0058,C-0057 [flags]\n$ kubescape scan contol C-0058 [flags]\n$ kubescape scan control 'privileged container,allowed hostpath' [flags]", - Short: fmt.Sprintf("The control you wish to use for scan. It must be present in at least one of the folloiwng frameworks: %s", clihandler.ValidFrameworks), + Short: fmt.Sprintf("The control you wish to use for scan. It must be present in at least one of the folloiwng frameworks: %s", getter.NativeFrameworks), Args: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { controls := strings.Split(args[0], ",") @@ -33,7 +34,7 @@ var controlCmd = &cobra.Command{ scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} if len(args) == 0 { - scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) + scanInfo = *clihandler.SetScanForGivenFrameworks(&scanInfo, getter.NativeFrameworks) } else { controls := strings.Split(args[0], ",") scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} diff --git a/clihandler/cmd/download.go b/clihandler/cmd/download.go index 6ed2dadf..53734548 100644 --- a/clihandler/cmd/download.go +++ b/clihandler/cmd/download.go @@ -6,14 +6,13 @@ import ( "github.com/armosec/kubescape/cautils" "github.com/armosec/kubescape/cautils/getter" - "github.com/armosec/kubescape/clihandler" "github.com/spf13/cobra" ) var downloadInfo cautils.DownloadInfo var downloadCmd = &cobra.Command{ - Use: fmt.Sprintf("download framework/control / [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), + Use: fmt.Sprintf("download framework/control / [flags]\nSupported frameworks: %s", getter.NativeFrameworks), Short: "Download framework/control", Long: ``, Args: func(cmd *cobra.Command, args []string) error { diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 002c6537..1ff31503 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -6,26 +6,19 @@ import ( "strings" "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/cautils/getter" "github.com/armosec/kubescape/clihandler" "github.com/armosec/opa-utils/reporthandling" "github.com/spf13/cobra" ) var frameworkCmd = &cobra.Command{ - Use: fmt.Sprintf("framework [``/`-`] [flags]\nExamples:\n$ kubescape scan framework nsa [flags]\n$ kubescape scan framework mitre,nsa [flags]\n$ kubescape scan framework 'nsa, mitre' [flags]\nSupported frameworks: %s", clihandler.ValidFrameworks), - Short: fmt.Sprintf("The framework you wish to use. Supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", ")), + Use: fmt.Sprintf("framework [``/`-`] [flags]\nExamples:\n$ kubescape scan framework nsa [flags]\n$ kubescape scan framework mitre,nsa [flags]\n$ kubescape scan framework 'nsa, mitre' [flags]\nSupported frameworks: %s", getter.NativeFrameworks), + Short: fmt.Sprintf("The framework you wish to use. Supported frameworks: %s", strings.Join(getter.NativeFrameworks, ", ")), Long: "Execute a scan on a running Kubernetes cluster or `yaml`/`json` files (use glob) or `-` for stdin", - ValidArgs: clihandler.SupportedFrameworks, + ValidArgs: getter.NativeFrameworks, Args: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - // "nsa, mitre" -> ["nsa", "mitre"] and nsa,mitre -> ["nsa", "mitre"] - frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") - for _, framework := range frameworks { - if !isValidFramework(strings.ToLower(framework)) { - return fmt.Errorf(fmt.Sprintf("supported frameworks: %s", strings.Join(clihandler.SupportedFrameworks, ", "))) - } - } - } else { + if len(args) < 0 { return fmt.Errorf("requires at least one framework name") } return nil @@ -35,14 +28,15 @@ var frameworkCmd = &cobra.Command{ scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} // If no framework provided, use all if len(args) == 0 { - scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(clihandler.SupportedFrameworks) + scanInfo = *clihandler.SetScanForGivenFrameworks(&scanInfo, getter.NativeFrameworks) + scanInfo.ScanAll = true } else { // Read frameworks from input args scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") scanInfo.PolicyIdentifier = SetScanForFirstFramework(frameworks) if len(frameworks) > 1 { - scanInfo.PolicyIdentifier = SetScanForGivenFrameworks(frameworks[1:]) + scanInfo = *clihandler.SetScanForGivenFrameworks(&scanInfo, frameworks[1:]) } if len(args) > 1 { @@ -63,7 +57,7 @@ var frameworkCmd = &cobra.Command{ } func isValidFramework(framework string) bool { - return cautils.StringInSlice(clihandler.SupportedFrameworks, framework) != cautils.ValueNotFound + return cautils.StringInSlice(getter.NativeFrameworks, framework) != cautils.ValueNotFound } func init() { @@ -71,15 +65,6 @@ func init() { scanInfo = cautils.ScanInfo{} scanInfo.FrameworkScan = true } -func SetScanForGivenFrameworks(frameworks []string) []reporthandling.PolicyIdentifier { - for _, framework := range frameworks { - newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindFramework - newPolicy.Name = framework - scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) - } - return scanInfo.PolicyIdentifier -} func SetScanForFirstFramework(frameworks []string) []reporthandling.PolicyIdentifier { newPolicy := reporthandling.PolicyIdentifier{} diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index 9b7723ab..5a876583 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/armosec/kubescape/cautils" - "github.com/armosec/kubescape/clihandler" + "github.com/armosec/kubescape/cautils/getter" "github.com/spf13/cobra" ) @@ -26,8 +26,10 @@ var scanCmd = &cobra.Command{ }, Run: func(cmd *cobra.Command, args []string) { if len(args) == 0 { - frameworkArgs := []string{clihandler.ValidFrameworks} - frameworkArgs = append(frameworkArgs, args...) + scanInfo.ScanAll = true + frameworks := getter.NativeFrameworks + frameworks = append(frameworks, args...) + frameworkArgs := []string{strings.Join(frameworks, ",")} frameworkCmd.RunE(cmd, frameworkArgs) } }, diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 981c617e..5b66bfca 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -3,7 +3,6 @@ package clihandler import ( "fmt" "os" - "strings" "github.com/armosec/armoapi-go/armotypes" "github.com/armosec/k8s-interface/k8sinterface" @@ -17,11 +16,9 @@ import ( "github.com/armosec/kubescape/resultshandling/printer" "github.com/armosec/kubescape/resultshandling/reporter" "github.com/armosec/opa-utils/reporthandling" + "github.com/golang/glog" ) -var SupportedFrameworks = []string{"nsa", "mitre", "armobest"} -var ValidFrameworks = strings.Join(SupportedFrameworks, ", ") - type componentInterfaces struct { clusterConfig cautils.IClusterConfig resourceHandler resourcehandler.IResourceHandler @@ -90,11 +87,34 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { printerHandler: printerHandler, } } +func setGetter(scanInfo *cautils.ScanInfo, customerGUID string) { + if len(scanInfo.UseFrom) > 0 { + //load from file + scanInfo.PolicyGetter = getter.NewLoadPolicy(scanInfo.UseFrom) + } else { + if customerGUID == "" || !scanInfo.FrameworkScan { + scanInfo.PolicyGetter = getter.NewDownloadReleasedPolicy() + } else { + g := getter.GetArmoAPIConnector() + g.SetCustomerGUID(customerGUID) + scanInfo.PolicyGetter = g + if scanInfo.ScanAll { + frameworks, err := g.GetCustomFrameworksForCustomer(customerGUID) + if err != nil { + glog.Error("could not get custom frameworks") + } + scanInfo = SetScanForGivenFrameworks(scanInfo, frameworks) + } + } + } +} func ScanCliSetup(scanInfo *cautils.ScanInfo) error { interfaces := getInterfaces(scanInfo) + setGetter(scanInfo, interfaces.clusterConfig.GetCustomerGUID()) + processNotification := make(chan *cautils.OPASessionObj) reportResults := make(chan *cautils.OPASessionObj) @@ -173,3 +193,13 @@ func Submit(submitInterfaces cliinterfaces.SubmitInterfaces) error { return nil } + +func SetScanForGivenFrameworks(scanInfo *cautils.ScanInfo, frameworks []string) *cautils.ScanInfo { + for _, framework := range frameworks { + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindFramework + newPolicy.Name = framework + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + } + return scanInfo +} From 37cdf1a19e34d871c2772ff4385d19901c63df44 Mon Sep 17 00:00:00 2001 From: Daniel-GrunbergerCA Date: Wed, 10 Nov 2021 18:57:05 +0200 Subject: [PATCH 60/70] erase repetitive frameworks --- cautils/getter/armoapiutils.go | 6 +++++- clihandler/initcli.go | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/cautils/getter/armoapiutils.go b/cautils/getter/armoapiutils.go index 98e76bd4..8f232a6c 100644 --- a/cautils/getter/armoapiutils.go +++ b/cautils/getter/armoapiutils.go @@ -50,7 +50,11 @@ func (armoAPI *ArmoAPI) GetCustomFrameworksForCustomer(customerGUID string) ([]s err = json.Unmarshal([]byte(respStr), &frs) for _, fr := range frs { - frameworkList = append(frameworkList, fr.Name) + if isNativeFramework(fr.Name) { + frameworkList = append(frameworkList, strings.ToLower(fr.Name)) + } else { + frameworkList = append(frameworkList, fr.Name) + } } return frameworkList, nil diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 5b66bfca..2b4c7b29 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -196,10 +196,22 @@ func Submit(submitInterfaces cliinterfaces.SubmitInterfaces) error { func SetScanForGivenFrameworks(scanInfo *cautils.ScanInfo, frameworks []string) *cautils.ScanInfo { for _, framework := range frameworks { - newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindFramework - newPolicy.Name = framework - scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + + if !contains(scanInfo, framework) { + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindFramework + newPolicy.Name = framework + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + } } return scanInfo } + +func contains(scanInfo *cautils.ScanInfo, framework string) bool { + for _, policy := range scanInfo.PolicyIdentifier { + if policy.Name == framework { + return true + } + } + return false +} From 0e5204ecb4c9cfc4f1074fc1cd7a7eb223107f8a Mon Sep 17 00:00:00 2001 From: dwertent Date: Thu, 11 Nov 2021 11:15:33 +0200 Subject: [PATCH 61/70] support custom frameworks --- cautils/getter/armoapi.go | 43 ++++++++++++++++++++++++ cautils/getter/armoapiutils.go | 24 ------------- cautils/getter/downloadreleasedpolicy.go | 7 ++-- cautils/scaninfo.go | 20 +++++++++++ clihandler/cmd/control.go | 2 +- clihandler/cmd/framework.go | 10 ++---- clihandler/cmd/scan.go | 1 - clihandler/initcli.go | 30 +++-------------- go.mod | 2 +- go.sum | 4 +-- 10 files changed, 76 insertions(+), 67 deletions(-) diff --git a/cautils/getter/armoapi.go b/cautils/getter/armoapi.go index b8649bc8..87e04395 100644 --- a/cautils/getter/armoapi.go +++ b/cautils/getter/armoapi.go @@ -1,6 +1,7 @@ package getter import ( + "encoding/json" "fmt" "net/http" "strings" @@ -189,6 +190,48 @@ func (armoAPI *ArmoAPI) GetControlsInputs(customerGUID, clusterName string) (map return nil, err } +func (armoAPI *ArmoAPI) ListCustomFrameworks(customerGUID string) ([]string, error) { + respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getListFrameworkURL(), nil) + if err != nil { + return nil, err + } + frs := []reporthandling.Framework{} + if err = json.Unmarshal([]byte(respStr), &frs); err != nil { + return nil, err + } + + frameworkList := []string{} + for _, fr := range frs { + if !isNativeFramework(fr.Name) { + frameworkList = append(frameworkList, fr.Name) + } + } + + return frameworkList, nil +} + +func (armoAPI *ArmoAPI) ListFrameworks(customerGUID string) ([]string, error) { + respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getListFrameworkURL(), nil) + if err != nil { + return nil, err + } + frs := []reporthandling.Framework{} + if err = json.Unmarshal([]byte(respStr), &frs); err != nil { + return nil, err + } + + frameworkList := []string{} + for _, fr := range frs { + if isNativeFramework(fr.Name) { + frameworkList = append(frameworkList, strings.ToLower(fr.Name)) + } else { + frameworkList = append(frameworkList, fr.Name) + } + } + + return frameworkList, nil +} + type TenantResponse struct { TenantID string `json:"tenantId"` Token string `json:"token"` diff --git a/cautils/getter/armoapiutils.go b/cautils/getter/armoapiutils.go index 8f232a6c..4991fcea 100644 --- a/cautils/getter/armoapiutils.go +++ b/cautils/getter/armoapiutils.go @@ -1,11 +1,8 @@ package getter import ( - "encoding/json" "net/url" "strings" - - "github.com/armosec/opa-utils/reporthandling" ) var NativeFrameworks = []string{"nsa", "mitre", "armobest"} @@ -39,27 +36,6 @@ func (armoAPI *ArmoAPI) getListFrameworkURL() string { return u.String() } - -func (armoAPI *ArmoAPI) GetCustomFrameworksForCustomer(customerGUID string) ([]string, error) { - respStr, err := HttpGetter(armoAPI.httpClient, armoAPI.getListFrameworkURL(), nil) - if err != nil { - return nil, err - } - frs := []reporthandling.Framework{} - frameworkList := []string{} - err = json.Unmarshal([]byte(respStr), &frs) - - for _, fr := range frs { - if isNativeFramework(fr.Name) { - frameworkList = append(frameworkList, strings.ToLower(fr.Name)) - } else { - frameworkList = append(frameworkList, fr.Name) - } - } - - return frameworkList, nil -} - func (armoAPI *ArmoAPI) getExceptionsURL(customerGUID, clusterName string) string { u := url.URL{} u.Scheme = "https" diff --git a/cautils/getter/downloadreleasedpolicy.go b/cautils/getter/downloadreleasedpolicy.go index 20fbbbc4..caac33d2 100644 --- a/cautils/getter/downloadreleasedpolicy.go +++ b/cautils/getter/downloadreleasedpolicy.go @@ -25,11 +25,8 @@ func NewDownloadReleasedPolicy() *DownloadReleasedPolicy { func (drp *DownloadReleasedPolicy) GetControl(policyName string) (*reporthandling.Control, error) { var control *reporthandling.Control var err error - if strings.HasPrefix(policyName, "C-") || strings.HasPrefix(policyName, "c-") { - control, err = drp.gs.GetOPAControlByID(policyName) - } else { - control, err = drp.gs.GetOPAControlByName(policyName) - } + + control, err = drp.gs.GetOPAControl(policyName) if err != nil { return nil, err } diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index 26aa806e..9a678aa9 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -106,3 +106,23 @@ func (scanInfo *ScanInfo) setOutputFile() { func (scanInfo *ScanInfo) ScanRunningCluster() bool { return len(scanInfo.InputPatterns) == 0 } + +func (scanInfo *ScanInfo) SetPolicyIdentifierForGivenFrameworks(frameworks []string) { + for _, framework := range frameworks { + if !scanInfo.contains(framework) { + newPolicy := reporthandling.PolicyIdentifier{} + newPolicy.Kind = reporthandling.KindFramework + newPolicy.Name = framework + scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) + } + } +} + +func (scanInfo *ScanInfo) contains(framework string) bool { + for _, policy := range scanInfo.PolicyIdentifier { + if policy.Name == framework { + return true + } + } + return false +} diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index 6e8d4a9a..df120324 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -34,7 +34,7 @@ var controlCmd = &cobra.Command{ scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} if len(args) == 0 { - scanInfo = *clihandler.SetScanForGivenFrameworks(&scanInfo, getter.NativeFrameworks) + scanInfo.SetPolicyIdentifierForGivenFrameworks(getter.NativeFrameworks) } else { controls := strings.Split(args[0], ",") scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 1ff31503..3d56cb36 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -18,7 +18,7 @@ var frameworkCmd = &cobra.Command{ Long: "Execute a scan on a running Kubernetes cluster or `yaml`/`json` files (use glob) or `-` for stdin", ValidArgs: getter.NativeFrameworks, Args: func(cmd *cobra.Command, args []string) error { - if len(args) < 0 { + if len(args) == 0 { return fmt.Errorf("requires at least one framework name") } return nil @@ -28,7 +28,7 @@ var frameworkCmd = &cobra.Command{ scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} // If no framework provided, use all if len(args) == 0 { - scanInfo = *clihandler.SetScanForGivenFrameworks(&scanInfo, getter.NativeFrameworks) + scanInfo.SetPolicyIdentifierForGivenFrameworks(getter.NativeFrameworks) scanInfo.ScanAll = true } else { // Read frameworks from input args @@ -36,7 +36,7 @@ var frameworkCmd = &cobra.Command{ frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") scanInfo.PolicyIdentifier = SetScanForFirstFramework(frameworks) if len(frameworks) > 1 { - scanInfo = *clihandler.SetScanForGivenFrameworks(&scanInfo, frameworks[1:]) + scanInfo.SetPolicyIdentifierForGivenFrameworks(frameworks[1:]) } if len(args) > 1 { @@ -56,10 +56,6 @@ var frameworkCmd = &cobra.Command{ }, } -func isValidFramework(framework string) bool { - return cautils.StringInSlice(getter.NativeFrameworks, framework) != cautils.ValueNotFound -} - func init() { scanCmd.AddCommand(frameworkCmd) scanInfo = cautils.ScanInfo{} diff --git a/clihandler/cmd/scan.go b/clihandler/cmd/scan.go index 5a876583..c69e5ab9 100644 --- a/clihandler/cmd/scan.go +++ b/clihandler/cmd/scan.go @@ -28,7 +28,6 @@ var scanCmd = &cobra.Command{ if len(args) == 0 { scanInfo.ScanAll = true frameworks := getter.NativeFrameworks - frameworks = append(frameworks, args...) frameworkArgs := []string{strings.Join(frameworks, ",")} frameworkCmd.RunE(cmd, frameworkArgs) } diff --git a/clihandler/initcli.go b/clihandler/initcli.go index 2b4c7b29..fb6acc9a 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -87,7 +87,7 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { printerHandler: printerHandler, } } -func setGetter(scanInfo *cautils.ScanInfo, customerGUID string) { +func setPolicyGetter(scanInfo *cautils.ScanInfo, customerGUID string) { if len(scanInfo.UseFrom) > 0 { //load from file scanInfo.PolicyGetter = getter.NewLoadPolicy(scanInfo.UseFrom) @@ -99,11 +99,11 @@ func setGetter(scanInfo *cautils.ScanInfo, customerGUID string) { g.SetCustomerGUID(customerGUID) scanInfo.PolicyGetter = g if scanInfo.ScanAll { - frameworks, err := g.GetCustomFrameworksForCustomer(customerGUID) + frameworks, err := g.ListCustomFrameworks(customerGUID) if err != nil { glog.Error("could not get custom frameworks") } - scanInfo = SetScanForGivenFrameworks(scanInfo, frameworks) + scanInfo.SetPolicyIdentifierForGivenFrameworks(frameworks) } } } @@ -113,7 +113,7 @@ func ScanCliSetup(scanInfo *cautils.ScanInfo) error { interfaces := getInterfaces(scanInfo) - setGetter(scanInfo, interfaces.clusterConfig.GetCustomerGUID()) + setPolicyGetter(scanInfo, interfaces.clusterConfig.GetCustomerGUID()) processNotification := make(chan *cautils.OPASessionObj) reportResults := make(chan *cautils.OPASessionObj) @@ -193,25 +193,3 @@ func Submit(submitInterfaces cliinterfaces.SubmitInterfaces) error { return nil } - -func SetScanForGivenFrameworks(scanInfo *cautils.ScanInfo, frameworks []string) *cautils.ScanInfo { - for _, framework := range frameworks { - - if !contains(scanInfo, framework) { - newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindFramework - newPolicy.Name = framework - scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) - } - } - return scanInfo -} - -func contains(scanInfo *cautils.ScanInfo, framework string) bool { - for _, policy := range scanInfo.PolicyIdentifier { - if policy.Name == framework { - return true - } - } - return false -} diff --git a/go.mod b/go.mod index 179abd04..6567897a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.17 require ( github.com/armosec/armoapi-go v0.0.8 github.com/armosec/k8s-interface v0.0.8 - github.com/armosec/opa-utils v0.0.24 + github.com/armosec/opa-utils v0.0.39 github.com/armosec/rbac-utils v0.0.1 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 diff --git a/go.sum b/go.sum index 9fc77b98..c5a8c3df 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/armosec/armoapi-go v0.0.8 h1:JPa9rZynuE2RucamDh6dsy/sjCScmWDsyt1zagJF github.com/armosec/armoapi-go v0.0.8/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= github.com/armosec/k8s-interface v0.0.8 h1:Eo3Qen4yFXxzVem49FNeij2ckyzHSAJ0w6PZMaSEIm8= github.com/armosec/k8s-interface v0.0.8/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= -github.com/armosec/opa-utils v0.0.24 h1:F36RpNVZpeUoEKV1j0xRDql60oi0mPm0whycu8+DL1s= -github.com/armosec/opa-utils v0.0.24/go.mod h1:JaE2a0kB2O22JZCqBBfOS9Pvh9rXs9xXXb9lVo01rTs= +github.com/armosec/opa-utils v0.0.39 h1:YOPMmwZaseqZT2/io918YycrMoJYu+ggbINnZJR9ZUA= +github.com/armosec/opa-utils v0.0.39/go.mod h1:JaE2a0kB2O22JZCqBBfOS9Pvh9rXs9xXXb9lVo01rTs= github.com/armosec/rbac-utils v0.0.1 h1:N2MI98F/0zbDjmRZ29CNElU1AXkFLk5csd/qAHOBdXY= github.com/armosec/rbac-utils v0.0.1/go.mod h1:pQ8CBiij8kSKV7aeZm9FMvtZN28VgA7LZcYyTWimq40= github.com/armosec/utils-go v0.0.2/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= From 1c31281b7b5d2bce8b038e29e82659465b2ed30a Mon Sep 17 00:00:00 2001 From: "lalafi@cyberarmor.io" Date: Thu, 11 Nov 2021 13:57:52 +0200 Subject: [PATCH 62/70] add baseScore to controlReport --- opaprocessor/processorhandler.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/opaprocessor/processorhandler.go b/opaprocessor/processorhandler.go index 5e17d37b..31ede043 100644 --- a/opaprocessor/processorhandler.go +++ b/opaprocessor/processorhandler.go @@ -120,6 +120,8 @@ func (opap *OPAProcessor) processControl(control *reporthandling.Control) (*repo controlReport := reporthandling.ControlReport{} controlReport.PortalBase = control.PortalBase controlReport.ControlID = control.ControlID + controlReport.BaseScore = control.BaseScore + controlReport.Control_ID = control.Control_ID // TODO: delete when 'id' is deprecated controlReport.Name = control.Name From a34ab1730761ad2a84b62447a071163d30e497de Mon Sep 17 00:00:00 2001 From: Thibault Le Reste Date: Sun, 14 Nov 2021 13:16:28 +0100 Subject: [PATCH 63/70] fix Kubernetes Hardening Guidance broken links --- README.md | 4 ++-- docs/run-options.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0e12d043..64dd1421 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ Set-ExecutionPolicy RemoteSigned -scope CurrentUser ### Examples -#### Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/News-Features/Feature-Stories/Article-View/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) +#### Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/Press-Room/News-Highlights/Article/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework and submit results to the [Kubescape SaaS version](https://portal.armo.cloud/) ``` kubescape scan framework nsa --submit ``` @@ -276,7 +276,7 @@ docker build -t kubescape -f build/Dockerfile . # Under the hood ## Tests -Kubescape is running the following tests according to what is defined by [Kubernetes Hardening Guidance by NSA and CISA](https://www.nsa.gov/News-Features/Feature-Stories/Article-View/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) +Kubescape is running the following tests according to what is defined by [Kubernetes Hardening Guidance by NSA and CISA](https://www.nsa.gov/Press-Room/News-Highlights/Article/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) * Non-root containers * Immutable container filesystem * Privileged containers diff --git a/docs/run-options.md b/docs/run-options.md index 9233f49b..f2e0adf2 100644 --- a/docs/run-options.md +++ b/docs/run-options.md @@ -25,7 +25,7 @@ kubescape scan framework nsa --exclude-namespaces kube-system,kube-public ### Examples -* Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/News-Features/Feature-Stories/Article-View/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework +* Scan a running Kubernetes cluster with [`nsa`](https://www.nsa.gov/Press-Room/News-Highlights/Article/Article/2716980/nsa-cisa-release-kubernetes-hardening-guidance/) framework ``` kubescape scan framework nsa --exclude-namespaces kube-system,kube-public ``` From 9b9940f708e99f91f2701298ce122e2963d94abb Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 14 Nov 2021 14:31:53 +0200 Subject: [PATCH 64/70] adding exceptions docs --- README.md | 6 +- examples/exceptions/README.md | 179 ++++++++++++++++++ .../exclude-allowed-hostPath-control.json | 22 +++ .../exclude-deployments-in-ns-default.json | 23 +++ .../exclude-kube-namespaces.json} | 6 + .../exceptions/exclude-nginx-in-minikube.json | 26 +++ 6 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 examples/exceptions/README.md create mode 100644 examples/exceptions/exclude-allowed-hostPath-control.json create mode 100644 examples/exceptions/exclude-deployments-in-ns-default.json rename examples/{exceptions.json => exceptions/exclude-kube-namespaces.json} (85%) create mode 100644 examples/exceptions/exclude-nginx-in-minikube.json diff --git a/README.md b/README.md index 0e12d043..e5075326 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Want to contribute? Want to discuss something? Have an issue? * [Overview](https://youtu.be/wdBkt_0Qhbg) * [Scanning Kubernetes YAML files](https://youtu.be/Ox6DaR7_4ZI) +* [Scan Kubescape on an air-gapped environment (offline support)](https://youtu.be/IGXL9s37smM) * [Managing exceptions in the Kubescape SaaS version](https://youtu.be/OzpvxGmCR80) ## Install on Windows @@ -158,8 +159,9 @@ kubescape scan framework nsa --format prometheus ``` #### Scan with exceptions, objects with exceptions will be presented as `exclude` and not `fail` +[Full documentation](examples/exceptions/README.md) ``` -kubescape scan framework nsa --exceptions examples/exceptions.json +kubescape scan framework nsa --exceptions examples/exceptions/exclude-kube-namespaces.json ``` #### Scan Helm charts - Render the helm chart using [`helm template`](https://helm.sh/docs/helm/helm_template/) and pass to stdout @@ -175,6 +177,8 @@ helm template bitnami/mysql --generate-name --dry-run | kubescape scan framework ### Offline Support +[Video tutorial](https://youtu.be/IGXL9s37smM) + It is possible to run Kubescape offline! First download the framework and then scan with `--use-from` flag diff --git a/examples/exceptions/README.md b/examples/exceptions/README.md new file mode 100644 index 00000000..75f23e89 --- /dev/null +++ b/examples/exceptions/README.md @@ -0,0 +1,179 @@ +# Kubescape Exceptions + +Kubescape Exceptions is the proper way of excluding failed resources from effecting the risk score. + +e.g. When a `kube-system` resource fails and it is ok, simply add the resource to the exceptions configurations. + +## Definitions + + +* `name`- Exception name - unique name representing the exception +* `policyType`- Do not change +* `actions`- List of available actions. Currently alertOnly is supported +* `resources`- List of resources to apply this exception on + * `designatorType: Attributes`- An attribute-based declaration {key: value} + Supported keys: + * `name`: k8s resource name (case-sensitive, regex supported) + * `kind`: k8s resource kind (case-sensitive, regex supported) + * `namespace`: k8s resource namespace (case-sensitive, regex supported) + * `cluster`: k8s cluster name (usually it is the `current-context`) (case-sensitive, regex supported) + * resource labels as key value (case-sensitive, regex NOT supported) +* `posturePolicies`- An attribute-based declaration {key: value} + * `frameworkName` - Framework names can be find [here](https://github.com/armosec/regolibrary/tree/master/frameworks) + * `controlName` - Control names can be find [here](https://github.com/armosec/regolibrary/tree/master/controls) + * `controlID` - Not yet supported + * `ruleName` - Rule names can be find [here](https://github.com/armosec/regolibrary/tree/master/rules) + + +## Usage + +The `resources` list and `posturePolicies` list are design to be a combination of the resources sand policies to exclude +> You must declare at least one resource and one policy + +e.g. If you wish to exclude all namespaces with the label "environment": "dev", the resource list should look as following: +``` +"resources": [ + { + "designatorType": "Attributes", + "attributes": { + "namespace": ".*", + "environment": "dev" + } + } +] +``` + +But if you wish to exclude all namespaces **OR** any resource with the label "environment": "dev", the resource list should look as following: +``` +"resources": [ + { + "designatorType": "Attributes", + "attributes": { + "namespace": ".*" + } + }, + { + "designatorType": "Attributes", + "attributes": { + "environment": "dev" + } + } +] +``` + +Same works with the `posturePolicies` list -> + +e.g. If you wish to exclude the resources decleared in the `resources` list that faild when scanning the `NSA` framework **AND** failed the `Allowed hostPath` control, the `posturePolicies` list should look as following: +``` +"posturePolicies": [ + { + "frameworkName": "NSA" + "controlName": "Allowed hostPath" + } +] +``` + +But if you wish to exclude the resources decleared in the `resources` list that faild when scanning the `NSA` framework **OR** failed the `Allowed hostPath` control, the `posturePolicies` list should look as following: +``` +"posturePolicies": [ + { + "frameworkName": "NSA" + }, + { + "controlName": "Allowed hostPath" + } +] +``` + +## Examples + +Here are some examples demonstrating the different ways the exceptions file can be configured + + +### Exclude control + +Exclude the ["Allowed hostPath" control](https://github.com/armosec/regolibrary/blob/master/controls/allowedhostpath.json#L2) by declaring the control in the `"posturePolicies"` section. + +The resources + +``` +[ + { + "name": "exclude-allowed-hostPath-control", + "policyType": "postureExceptionPolicy", + "actions": [ + "alertOnly" + ], + "resources": [ + { + "designatorType": "Attributes", + "attributes": { + "kind": ".*" + } + } + ], + "posturePolicies": [ + { + "controlName": "Allowed hostPath" + } + ] + } +] +``` + +### Exclude deployments in the default namespace that failed the "Allowed hostPath" control +``` +[ + { + "name": "exclude-deployments-in-ns-default", + "policyType": "postureExceptionPolicy", + "actions": [ + "alertOnly" + ], + "resources": [ + { + "designatorType": "Attributes", + "attributes": { + "namespace": "default", + "kind": "Deployment" + } + } + ], + "posturePolicies": [ + { + "controlName": "Allowed hostPath" + } + ] + } +] +``` + +### Exclude resources with label "app=nginx" running in a minikube cluster that failed the "NSA" or "MITRE" framework +``` +[ + { + "name": "exclude-nginx-minikube", + "policyType": "postureExceptionPolicy", + "actions": [ + "alertOnly" + ], + "resources": [ + { + "designatorType": "Attributes", + "attributes": { + "cluster": "minikube", + "app": "nginx" + } + } + ], + "posturePolicies": [ + { + "frameworkName": "NSA" + }, + { + "frameworkName": "MITRE" + } + ] + } +] +``` \ No newline at end of file diff --git a/examples/exceptions/exclude-allowed-hostPath-control.json b/examples/exceptions/exclude-allowed-hostPath-control.json new file mode 100644 index 00000000..106dfaa6 --- /dev/null +++ b/examples/exceptions/exclude-allowed-hostPath-control.json @@ -0,0 +1,22 @@ +[ + { + "name": "exclude-allowed-hostPath-control", + "policyType": "postureExceptionPolicy", + "actions": [ + "alertOnly" + ], + "resources": [ + { + "designatorType": "Attributes", + "attributes": { + "kind": ".*" + } + } + ], + "posturePolicies": [ + { + "controlName": "Allowed hostPath" + } + ] + } +] \ No newline at end of file diff --git a/examples/exceptions/exclude-deployments-in-ns-default.json b/examples/exceptions/exclude-deployments-in-ns-default.json new file mode 100644 index 00000000..dceb4010 --- /dev/null +++ b/examples/exceptions/exclude-deployments-in-ns-default.json @@ -0,0 +1,23 @@ +[ + { + "name": "exclude-deployments-in-ns-default", + "policyType": "postureExceptionPolicy", + "actions": [ + "alertOnly" + ], + "resources": [ + { + "designatorType": "Attributes", + "attributes": { + "namespace": "default", + "kind": "Deployment" + } + } + ], + "posturePolicies": [ + { + "controlName": "Allowed hostPath" + } + ] + } +] \ No newline at end of file diff --git a/examples/exceptions.json b/examples/exceptions/exclude-kube-namespaces.json similarity index 85% rename from examples/exceptions.json rename to examples/exceptions/exclude-kube-namespaces.json index 7f67a52b..a8aecd4a 100644 --- a/examples/exceptions.json +++ b/examples/exceptions/exclude-kube-namespaces.json @@ -28,6 +28,12 @@ "posturePolicies": [ { "frameworkName": "NSA" + }, + { + "frameworkName": "MITRE" + }, + { + "frameworkName": "ArmoBest" } ] } diff --git a/examples/exceptions/exclude-nginx-in-minikube.json b/examples/exceptions/exclude-nginx-in-minikube.json new file mode 100644 index 00000000..c0e3cdfe --- /dev/null +++ b/examples/exceptions/exclude-nginx-in-minikube.json @@ -0,0 +1,26 @@ +[ + { + "name": "exclude-nginx-in-minikube", + "policyType": "postureExceptionPolicy", + "actions": [ + "alertOnly" + ], + "resources": [ + { + "designatorType": "Attributes", + "attributes": { + "cluster": "minikube", + "app": "nginx" + } + } + ], + "posturePolicies": [ + { + "frameworkName": "NSA" + }, + { + "frameworkName": "MITRE" + } + ] + } +] \ No newline at end of file From fea84c9652a7cfabfe8afae756662cdf65c1ed28 Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 14 Nov 2021 14:42:10 +0200 Subject: [PATCH 65/70] update opa-utils pkg version --- go.mod | 4 ++-- go.sum | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 6567897a..ac553962 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,9 @@ module github.com/armosec/kubescape go 1.17 require ( - github.com/armosec/armoapi-go v0.0.8 + github.com/armosec/armoapi-go v0.0.23 github.com/armosec/k8s-interface v0.0.8 - github.com/armosec/opa-utils v0.0.39 + github.com/armosec/opa-utils v0.0.42 github.com/armosec/rbac-utils v0.0.1 github.com/armosec/utils-go v0.0.3 github.com/briandowns/spinner v1.16.0 diff --git a/go.sum b/go.sum index c5a8c3df..d298636d 100644 --- a/go.sum +++ b/go.sum @@ -84,13 +84,12 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armosec/armoapi-go v0.0.2/go.mod h1:vIK17yoKbJRQyZXWWLe3AqfqCRITxW8qmSkApyq5xFs= -github.com/armosec/armoapi-go v0.0.7/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= -github.com/armosec/armoapi-go v0.0.8 h1:JPa9rZynuE2RucamDh6dsy/sjCScmWDsyt1zagJFCDo= -github.com/armosec/armoapi-go v0.0.8/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= +github.com/armosec/armoapi-go v0.0.23 h1:jqoLIWM5CR7DCD9fpFgN0ePqtHvOCoZv/XzCwsUluJU= +github.com/armosec/armoapi-go v0.0.23/go.mod h1:iaVVGyc23QGGzAdv4n+szGQg3Rbpixn9yQTU3qWRpaw= github.com/armosec/k8s-interface v0.0.8 h1:Eo3Qen4yFXxzVem49FNeij2ckyzHSAJ0w6PZMaSEIm8= github.com/armosec/k8s-interface v0.0.8/go.mod h1:xxS+V5QT3gVQTwZyAMMDrYLWGrfKOpiJ7Jfhfa0w9sM= -github.com/armosec/opa-utils v0.0.39 h1:YOPMmwZaseqZT2/io918YycrMoJYu+ggbINnZJR9ZUA= -github.com/armosec/opa-utils v0.0.39/go.mod h1:JaE2a0kB2O22JZCqBBfOS9Pvh9rXs9xXXb9lVo01rTs= +github.com/armosec/opa-utils v0.0.42 h1:7YzQJNVBmM0+1nWOAiUgDt+mvlVEwApg80FjMh4oxXo= +github.com/armosec/opa-utils v0.0.42/go.mod h1:OqewZoSqKD5udtQ4lGFixb8yyFNqLq9zqinlAL6KSjM= github.com/armosec/rbac-utils v0.0.1 h1:N2MI98F/0zbDjmRZ29CNElU1AXkFLk5csd/qAHOBdXY= github.com/armosec/rbac-utils v0.0.1/go.mod h1:pQ8CBiij8kSKV7aeZm9FMvtZN28VgA7LZcYyTWimq40= github.com/armosec/utils-go v0.0.2/go.mod h1:itWmRLzRdsnwjpEOomL0mBWGnVNNIxSjDAdyc+b0iUo= From 4f6a3e39d06cc13416d598a5a2a8507d5e11c958 Mon Sep 17 00:00:00 2001 From: Rotem Refael Date: Mon, 15 Nov 2021 21:59:30 +0200 Subject: [PATCH 66/70] Update SAAS link --- cautils/customerloader.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cautils/customerloader.go b/cautils/customerloader.go index 872aaf64..6bbacf0a 100644 --- a/cautils/customerloader.go +++ b/cautils/customerloader.go @@ -131,7 +131,7 @@ func (c *EmptyConfig) GetDefaultNS() string { return k8sinterf func (c *EmptyConfig) GetBackendAPI() getter.IBackend { return nil } // TODO: return mock obj func (c *EmptyConfig) GetClusterName() string { return adoptClusterName(k8sinterface.GetClusterName()) } func (c *EmptyConfig) GenerateURL() { - message := fmt.Sprintf("\nCheckout for more cool features: https://%s\n", getter.GetArmoAPIConnector().GetFrontendURL()) + message := fmt.Sprintf("\nYou can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more by registering here: https://%s\n", getter.GetArmoAPIConnector().GetFrontendURL()) InfoTextDisplay(os.Stdout, fmt.Sprintf("\n%s\n", message)) } @@ -161,7 +161,7 @@ func (c *ClusterConfig) GetDefaultNS() string { return c.defau func (c *ClusterConfig) GetBackendAPI() getter.IBackend { return c.backendAPI } func (c *ClusterConfig) GenerateURL() { - message := "Checkout for more cool features: " + message := "You can see the results in a user-friendly UI, choose your preferred compliance framework, check risk results history and trends, manage exceptions, get remediation recommendations and much more by registering here: " u := url.URL{} u.Scheme = "https" From b7a51a2495eb61d5552bb8fbfc41914869d0b8f2 Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 16 Nov 2021 15:57:23 +0200 Subject: [PATCH 67/70] fixed stdin support --- cautils/scaninfo.go | 34 ++++-------------- clihandler/cmd/control.go | 33 ++++++++++------- clihandler/cmd/framework.go | 57 ++++++++++++++++++----------- clihandler/initcli.go | 5 +-- examples/exceptions/README.md | 12 +++---- smoke_testing/smoke_utils.py | 8 ++++- smoke_testing/test_scan.py | 68 +++++++++++++++++++++++++++++++---- 7 files changed, 142 insertions(+), 75 deletions(-) diff --git a/cautils/scaninfo.go b/cautils/scaninfo.go index 9a678aa9..02f667b2 100644 --- a/cautils/scaninfo.go +++ b/cautils/scaninfo.go @@ -1,8 +1,6 @@ package cautils import ( - "io" - "os" "path/filepath" "github.com/armosec/kubescape/cautils/getter" @@ -69,24 +67,6 @@ func (scanInfo *ScanInfo) setUseFrom() { } } -func (scanInfo *ScanInfo) SetInputPatterns(args []string) error { - if args[1] != "-" { - scanInfo.InputPatterns = args[1:] - } else { // store stout to file - tempFile, err := os.CreateTemp(".", "tmp-kubescape*.yaml") - if err != nil { - return err - } - defer os.Remove(tempFile.Name()) - - if _, err := io.Copy(tempFile, os.Stdin); err != nil { - return err - } - scanInfo.InputPatterns = []string{tempFile.Name()} - } - return nil -} - func (scanInfo *ScanInfo) setOutputFile() { if scanInfo.Output == "" { return @@ -107,20 +87,20 @@ func (scanInfo *ScanInfo) ScanRunningCluster() bool { return len(scanInfo.InputPatterns) == 0 } -func (scanInfo *ScanInfo) SetPolicyIdentifierForGivenFrameworks(frameworks []string) { - for _, framework := range frameworks { - if !scanInfo.contains(framework) { +func (scanInfo *ScanInfo) SetPolicyIdentifiers(policies []string, kind reporthandling.NotificationPolicyKind) { + for _, policy := range policies { + if !scanInfo.contains(policy) { newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindFramework - newPolicy.Name = framework + newPolicy.Kind = kind // reporthandling.KindFramework + newPolicy.Name = policy scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) } } } -func (scanInfo *ScanInfo) contains(framework string) bool { +func (scanInfo *ScanInfo) contains(policyName string) bool { for _, policy := range scanInfo.PolicyIdentifier { - if policy.Name == framework { + if policy.Name == policyName { return true } } diff --git a/clihandler/cmd/control.go b/clihandler/cmd/control.go index df120324..a0772767 100644 --- a/clihandler/cmd/control.go +++ b/clihandler/cmd/control.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "io" "os" "strings" @@ -21,7 +22,7 @@ var controlCmd = &cobra.Command{ controls := strings.Split(args[0], ",") if len(controls) > 1 { if controls[1] == "" { - return fmt.Errorf("usage: ,") + return fmt.Errorf("usage: ,") } } } else { @@ -34,23 +35,31 @@ var controlCmd = &cobra.Command{ scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} if len(args) == 0 { - scanInfo.SetPolicyIdentifierForGivenFrameworks(getter.NativeFrameworks) - } else { - controls := strings.Split(args[0], ",") - scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - scanInfo.PolicyIdentifier = setScanForFirstControl(controls) + scanInfo.SetPolicyIdentifiers(getter.NativeFrameworks, reporthandling.KindFramework) + scanInfo.ScanAll = true + } else { // expected control or list of control sepparated by "," - if len(controls) > 1 { - scanInfo.PolicyIdentifier = SetScanForGivenControls(controls[1:]) - } + // Read controls from input args + scanInfo.SetPolicyIdentifiers(strings.Split(args[0], ","), reporthandling.KindControl) if len(args) > 1 { - // Set scan to run on yamls - if err := scanInfo.SetInputPatterns(args); err != nil { - return err + if len(args[1:]) == 0 || args[1] != "-" { + scanInfo.InputPatterns = args[1:] + } else { // store stdin to file - do NOT move to separate function !! + tempFile, err := os.CreateTemp(".", "tmp-kubescape*.yaml") + if err != nil { + return err + } + defer os.Remove(tempFile.Name()) + + if _, err := io.Copy(tempFile, os.Stdin); err != nil { + return err + } + scanInfo.InputPatterns = []string{tempFile.Name()} } } } + scanInfo.FrameworkScan = false scanInfo.Init() cautils.SetSilentMode(scanInfo.Silent) diff --git a/clihandler/cmd/framework.go b/clihandler/cmd/framework.go index 3d56cb36..e381ecff 100644 --- a/clihandler/cmd/framework.go +++ b/clihandler/cmd/framework.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "io" "os" "strings" @@ -18,34 +19,48 @@ var frameworkCmd = &cobra.Command{ Long: "Execute a scan on a running Kubernetes cluster or `yaml`/`json` files (use glob) or `-` for stdin", ValidArgs: getter.NativeFrameworks, Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { + if len(args) > 0 { + frameworks := strings.Split(args[0], ",") + if len(frameworks) > 1 { + if frameworks[1] == "" { + return fmt.Errorf("usage: ,") + } + } + } else { return fmt.Errorf("requires at least one framework name") } return nil }, RunE: func(cmd *cobra.Command, args []string) error { flagValidationFramework() - scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - // If no framework provided, use all - if len(args) == 0 { - scanInfo.SetPolicyIdentifierForGivenFrameworks(getter.NativeFrameworks) + var frameworks []string + + if len(args) == 0 { // scan all frameworks + frameworks = getter.NativeFrameworks scanInfo.ScanAll = true } else { // Read frameworks from input args - scanInfo.PolicyIdentifier = []reporthandling.PolicyIdentifier{} - frameworks := strings.Split(strings.Join(strings.Fields(args[0]), ""), ",") - scanInfo.PolicyIdentifier = SetScanForFirstFramework(frameworks) - if len(frameworks) > 1 { - scanInfo.SetPolicyIdentifierForGivenFrameworks(frameworks[1:]) - } + frameworks = strings.Split(args[0], ",") if len(args) > 1 { - // expected yaml/url input - if err := scanInfo.SetInputPatterns(args); err != nil { - return err + if len(args[1:]) == 0 || args[1] != "-" { + scanInfo.InputPatterns = args[1:] + } else { // store stdin to file - do NOT move to separate function !! + tempFile, err := os.CreateTemp(".", "tmp-kubescape*.yaml") + if err != nil { + return err + } + defer os.Remove(tempFile.Name()) + + if _, err := io.Copy(tempFile, os.Stdin); err != nil { + return err + } + scanInfo.InputPatterns = []string{tempFile.Name()} } } } + scanInfo.SetPolicyIdentifiers(frameworks, reporthandling.KindFramework) + scanInfo.Init() cautils.SetSilentMode(scanInfo.Silent) err := clihandler.ScanCliSetup(&scanInfo) @@ -62,13 +77,13 @@ func init() { scanInfo.FrameworkScan = true } -func SetScanForFirstFramework(frameworks []string) []reporthandling.PolicyIdentifier { - newPolicy := reporthandling.PolicyIdentifier{} - newPolicy.Kind = reporthandling.KindFramework - newPolicy.Name = frameworks[0] - scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) - return scanInfo.PolicyIdentifier -} +// func SetScanForFirstFramework(frameworks []string) []reporthandling.PolicyIdentifier { +// newPolicy := reporthandling.PolicyIdentifier{} +// newPolicy.Kind = reporthandling.KindFramework +// newPolicy.Name = frameworks[0] +// scanInfo.PolicyIdentifier = append(scanInfo.PolicyIdentifier, newPolicy) +// return scanInfo.PolicyIdentifier +// } func flagValidationFramework() { if scanInfo.Submit && scanInfo.Local { diff --git a/clihandler/initcli.go b/clihandler/initcli.go index fb6acc9a..f74dac57 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -101,9 +101,10 @@ func setPolicyGetter(scanInfo *cautils.ScanInfo, customerGUID string) { if scanInfo.ScanAll { frameworks, err := g.ListCustomFrameworks(customerGUID) if err != nil { - glog.Error("could not get custom frameworks") + glog.Error("failed to get custom frameworks") // handle error + return } - scanInfo.SetPolicyIdentifierForGivenFrameworks(frameworks) + scanInfo.SetPolicyIdentifiers(frameworks, reporthandling.KindFramework) } } } diff --git a/examples/exceptions/README.md b/examples/exceptions/README.md index 75f23e89..4fd13019 100644 --- a/examples/exceptions/README.md +++ b/examples/exceptions/README.md @@ -27,10 +27,10 @@ e.g. When a `kube-system` resource fails and it is ok, simply add the resource t ## Usage -The `resources` list and `posturePolicies` list are design to be a combination of the resources sand policies to exclude +The `resources` list and `posturePolicies` list are design to be a combination of the resources and policies to exclude > You must declare at least one resource and one policy -e.g. If you wish to exclude all namespaces with the label "environment": "dev", the resource list should look as following: +e.g. If you wish to exclude all namespaces with the label `"environment": "dev"`, the resource list should look as following: ``` "resources": [ { @@ -43,7 +43,7 @@ e.g. If you wish to exclude all namespaces with the label "environment": "dev", ] ``` -But if you wish to exclude all namespaces **OR** any resource with the label "environment": "dev", the resource list should look as following: +But if you wish to exclude all namespaces **OR** any resource with the label `"environment": "dev"`, the resource list should look as following: ``` "resources": [ { @@ -63,17 +63,17 @@ But if you wish to exclude all namespaces **OR** any resource with the label "en Same works with the `posturePolicies` list -> -e.g. If you wish to exclude the resources decleared in the `resources` list that faild when scanning the `NSA` framework **AND** failed the `Allowed hostPath` control, the `posturePolicies` list should look as following: +e.g. If you wish to exclude the resources declared in the `resources` list that failed when scanning the `NSA` framework **AND** failed the `Allowed hostPath` control, the `posturePolicies` list should look as following: ``` "posturePolicies": [ { - "frameworkName": "NSA" + "frameworkName": "NSA", "controlName": "Allowed hostPath" } ] ``` -But if you wish to exclude the resources decleared in the `resources` list that faild when scanning the `NSA` framework **OR** failed the `Allowed hostPath` control, the `posturePolicies` list should look as following: +But if you wish to exclude the resources declared in the `resources` list that failed when scanning the `NSA` framework **OR** failed the `Allowed hostPath` control, the `posturePolicies` list should look as following: ``` "posturePolicies": [ { diff --git a/smoke_testing/smoke_utils.py b/smoke_testing/smoke_utils.py index 7b56349e..3bff46d9 100644 --- a/smoke_testing/smoke_utils.py +++ b/smoke_testing/smoke_utils.py @@ -7,7 +7,13 @@ def get_exec_from_args(args: list): def run_command(command): try: - return f"{subprocess.check_output(command, stderr=subprocess.STDOUT)}" + return f"{subprocess.check_output(command, stdin=subprocess.PIPE, stderr=subprocess.STDOUT)}" except Exception as e: return f"{e}" + +def assertion(msg): + errors = ["Error: invalid parameter", "exit status 1"] + for e in errors: + assert e not in msg, msg + diff --git a/smoke_testing/test_scan.py b/smoke_testing/test_scan.py index d886d0da..942d79aa 100644 --- a/smoke_testing/test_scan.py +++ b/smoke_testing/test_scan.py @@ -3,15 +3,71 @@ import smoke_utils import sys -def full_scan(kubescape_exec: str): - return smoke_utils.run_command(command=[kubescape_exec, "scan", "framework", "nsa", os.path.join("..", "*.yaml")]) +all_files = os.path.join("..", "examples", "online-boutique", "*.yaml") +single_file = os.path.join("..", "examples", "online-boutique", "frontend.yaml") + + +def scan_all(kubescape_exec: str): + return smoke_utils.run_command(command=[kubescape_exec, "scan", all_files]) + + +def scan_control_name(kubescape_exec: str): + return smoke_utils.run_command(command=[kubescape_exec, "scan", "control", 'Allowed hostPath', all_files]) + + +def scan_control_id(kubescape_exec: str): + return smoke_utils.run_command(command=[kubescape_exec, "scan", "control", 'C-0006', all_files]) + + +def scan_controls(kubescape_exec: str): + return smoke_utils.run_command(command=[kubescape_exec, "scan", "control", 'Allowed hostPath,Allow privilege escalation', all_files]) + + +def scan_framework(kubescape_exec: str): + return smoke_utils.run_command(command=[kubescape_exec, "scan", "framework", "nsa", all_files]) + + +def scan_frameworks(kubescape_exec: str): + return smoke_utils.run_command(command=[kubescape_exec, "scan", "framework", "nsa,mitre,armobest", all_files]) + + +def scan_from_stdin(kubescape_exec: str): + return smoke_utils.run_command(command=["cat", single_file, "|", kubescape_exec, "scan", "framework", "nsa", "-"]) def run(kubescape_exec: str): - # return - print("Testing E2E yaml files") - msg = full_scan(kubescape_exec=kubescape_exec) - assert "exit status 1" not in msg, msg + print("Testing E2E on yaml files") + + # TODO - fix support + # print("Testing scan all yaml files") + # msg = scan_all(kubescape_exec=kubescape_exec) + # smoke_utils.assertion(msg) + + print("Testing scan control name") + msg = scan_control_name(kubescape_exec=kubescape_exec) + smoke_utils.assertion(msg) + + print("Testing scan control id") + msg = scan_control_id(kubescape_exec=kubescape_exec) + smoke_utils.assertion(msg) + + print("Testing scan controls") + msg = scan_controls(kubescape_exec=kubescape_exec) + smoke_utils.assertion(msg) + + print("Testing scan framework") + msg = scan_framework(kubescape_exec=kubescape_exec) + smoke_utils.assertion(msg) + + print("Testing scan frameworks") + msg = scan_frameworks(kubescape_exec=kubescape_exec) + smoke_utils.assertion(msg) + + # TODO - fix test + # print("Testing scan from stdin") + # msg = scan_from_stdin(kubescape_exec=kubescape_exec) + # smoke_utils.assertion(msg) + print("Done E2E yaml files") From bae0ca62b842b32466047fdca3f1d6b51c5639be Mon Sep 17 00:00:00 2001 From: dwertent Date: Tue, 16 Nov 2021 16:03:31 +0200 Subject: [PATCH 68/70] update smoke testing --- smoke_testing/test_scan.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/smoke_testing/test_scan.py b/smoke_testing/test_scan.py index 942d79aa..0e18fb8a 100644 --- a/smoke_testing/test_scan.py +++ b/smoke_testing/test_scan.py @@ -3,7 +3,8 @@ import smoke_utils import sys -all_files = os.path.join("..", "examples", "online-boutique", "*.yaml") +all_files = os.path.join("..", "*.yaml") +# all_files = os.path.join("..", "examples", "online-boutique", "*.yaml") single_file = os.path.join("..", "examples", "online-boutique", "frontend.yaml") From e869ce4a64bf05183a78cb996b7df0cb9262f06a Mon Sep 17 00:00:00 2001 From: dwertent Date: Sun, 21 Nov 2021 08:49:24 +0200 Subject: [PATCH 69/70] comment out policy version check --- cautils/versioncheck.go | 17 ++++++++++++---- clihandler/initcli.go | 23 +-------------------- clihandler/initcliutils.go | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 26 deletions(-) create mode 100644 clihandler/initcliutils.go diff --git a/cautils/versioncheck.go b/cautils/versioncheck.go index 8483f07e..7d9119ba 100644 --- a/cautils/versioncheck.go +++ b/cautils/versioncheck.go @@ -57,6 +57,9 @@ func NewVersionCheckHandler() *VersionCheckHandler { } } func NewVersionCheckRequest(buildNumber, frameworkName, frameworkVersion, scanningTarget string) *VersionCheckRequest { + if buildNumber == "" { + buildNumber = "unknown" + } return &VersionCheckRequest{ Client: "kubescape", ClientVersion: buildNumber, @@ -82,17 +85,23 @@ func (v *VersionCheckHandler) CheckLatestVersion(versionData *VersionCheckReques fmt.Println(warningMessage(latestVersion.Client, latestVersion.ClientUpdate)) } - if latestVersion.FrameworkUpdate != "" { - fmt.Println(warningMessage(latestVersion.Framework, latestVersion.FrameworkUpdate)) + // TODO - Enable after supporting framework version + // if latestVersion.FrameworkUpdate != "" { + // fmt.Println(warningMessage(latestVersion.Framework, latestVersion.FrameworkUpdate)) + // } + + if latestVersion.Message != "" { + fmt.Println(latestVersion.Message) } + return nil } func (v *VersionCheckHandler) getLatestVersion(versionData *VersionCheckRequest) (*VersionCheckResponse, error) { - reqBody, err := json.Marshal(versionData) + reqBody, err := json.Marshal(*versionData) if err != nil { - return nil, fmt.Errorf("in 'CheckLatestVersion' failed to json.Marshal, reason: %v", err) + return nil, fmt.Errorf("in 'CheckLatestVersion' failed to json.Marshal, reason: %s", err.Error()) } resp, err := getter.HttpPost(http.DefaultClient, v.versionURL, map[string]string{"Content-Type": "application/json"}, reqBody) diff --git a/clihandler/initcli.go b/clihandler/initcli.go index f74dac57..852af93b 100644 --- a/clihandler/initcli.go +++ b/clihandler/initcli.go @@ -26,27 +26,6 @@ type componentInterfaces struct { printerHandler printer.IPrinter } -func getReporter(scanInfo *cautils.ScanInfo) reporter.IReport { - if !scanInfo.Submit { - return reporter.NewReportMock() - } - if !scanInfo.FrameworkScan { - return reporter.NewReportMock() - } - - return reporter.NewReportEventReceiver("", "") -} - -func getFieldSelector(scanInfo *cautils.ScanInfo) resourcehandler.IFieldSelector { - if scanInfo.IncludeNamespaces != "" { - return resourcehandler.NewIncludeSelector(scanInfo.IncludeNamespaces) - } - if scanInfo.ExcludedNamespaces != "" { - return resourcehandler.NewExcludeSelector(scanInfo.ExcludedNamespaces) - } - - return &resourcehandler.EmptySelector{} -} func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { var resourceHandler resourcehandler.IResourceHandler var clusterConfig cautils.IClusterConfig @@ -74,7 +53,7 @@ func getInterfaces(scanInfo *cautils.ScanInfo) componentInterfaces { } v := cautils.NewIVersionCheckHandler() - v.CheckLatestVersion(cautils.NewVersionCheckRequest(cautils.BuildNumber, "", "", scanningTarget)) + v.CheckLatestVersion(cautils.NewVersionCheckRequest(cautils.BuildNumber, policyIdentifierNames(scanInfo.PolicyIdentifier), "", scanningTarget)) // setup printer printerHandler := printer.GetPrinter(scanInfo.Format) diff --git a/clihandler/initcliutils.go b/clihandler/initcliutils.go new file mode 100644 index 00000000..38d647b9 --- /dev/null +++ b/clihandler/initcliutils.go @@ -0,0 +1,41 @@ +package clihandler + +import ( + "github.com/armosec/kubescape/cautils" + "github.com/armosec/kubescape/resourcehandler" + "github.com/armosec/kubescape/resultshandling/reporter" + "github.com/armosec/opa-utils/reporthandling" +) + +func getReporter(scanInfo *cautils.ScanInfo) reporter.IReport { + if !scanInfo.Submit { + return reporter.NewReportMock() + } + if !scanInfo.FrameworkScan { + return reporter.NewReportMock() + } + + return reporter.NewReportEventReceiver("", "") +} + +func getFieldSelector(scanInfo *cautils.ScanInfo) resourcehandler.IFieldSelector { + if scanInfo.IncludeNamespaces != "" { + return resourcehandler.NewIncludeSelector(scanInfo.IncludeNamespaces) + } + if scanInfo.ExcludedNamespaces != "" { + return resourcehandler.NewExcludeSelector(scanInfo.ExcludedNamespaces) + } + + return &resourcehandler.EmptySelector{} +} + +func policyIdentifierNames(pi []reporthandling.PolicyIdentifier) string { + policiesNames := "" + for i := range pi { + policiesNames += pi[i].Name + if i+1 < len(pi) { + policiesNames += "," + } + } + return policiesNames +} From 53741ec26e1150d43b4d65d333853fb45b919de3 Mon Sep 17 00:00:00 2001 From: Rotem Refael <48656234+rotemamsa@users.noreply.github.com> Date: Sun, 21 Nov 2021 10:13:31 +0200 Subject: [PATCH 70/70] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eaa06138..d885269d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ curl -s https://raw.githubusercontent.com/armosec/kubescape/master/install.sh | ## Run: ``` -kubescape scan framework nsa +kubescape scan framework nsa --submit ```