diff --git a/embedded.go b/embedded.go index 457db6ed1..6d68911e4 100644 --- a/embedded.go +++ b/embedded.go @@ -3,6 +3,8 @@ package starboard import ( _ "embed" + "github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1" + corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/scheme" @@ -17,12 +19,19 @@ var ( configAuditReportsCRD []byte //go:embed deploy/crd/clusterconfigauditreports.crd.yaml clusterConfigAuditReportsCRD []byte + //go:embed deploy/crd/clustercompliancereports.crd.yaml + clusterComplianceReportsCRD []byte + //go:embed deploy/crd/clustercompliancedetailreports.crd.yaml + clusterComplianceDetailReportsCRD []byte //go:embed deploy/crd/ciskubebenchreports.crd.yaml kubeBenchReportsCRD []byte //go:embed deploy/crd/kubehunterreports.crd.yaml kubeHunterReportsCRD []byte //go:embed deploy/static/04-starboard-operator.policies.yaml policies []byte + + //go:embed deploy/specs/nsa-1.0.yaml + nsaSpecV10 []byte ) func PoliciesConfigMap() (corev1.ConfigMap, error) { @@ -50,6 +59,14 @@ func GetClusterConfigAuditReportsCRD() (apiextensionsv1.CustomResourceDefinition return getCRDFromBytes(clusterConfigAuditReportsCRD) } +func GetClusterComplianceReportsCRD() (apiextensionsv1.CustomResourceDefinition, error) { + return getCRDFromBytes(clusterComplianceReportsCRD) +} + +func GetClusterComplianceDetailReportsCRD() (apiextensionsv1.CustomResourceDefinition, error) { + return getCRDFromBytes(clusterComplianceDetailReportsCRD) +} + func GetCISKubeBenchReportsCRD() (apiextensionsv1.CustomResourceDefinition, error) { return getCRDFromBytes(kubeBenchReportsCRD) } @@ -58,6 +75,10 @@ func GetKubeHunterReportsCRD() (apiextensionsv1.CustomResourceDefinition, error) return getCRDFromBytes(kubeHunterReportsCRD) } +func GetNSASpecV10() (v1alpha1.ClusterComplianceReport, error) { + return getComplianceSpec(nsaSpecV10) +} + func getCRDFromBytes(bytes []byte) (apiextensionsv1.CustomResourceDefinition, error) { var crd apiextensionsv1.CustomResourceDefinition _, _, err := scheme.Codecs.UniversalDecoder().Decode(bytes, nil, &crd) @@ -66,3 +87,12 @@ func getCRDFromBytes(bytes []byte) (apiextensionsv1.CustomResourceDefinition, er } return crd, nil } + +func getComplianceSpec(bytes []byte) (v1alpha1.ClusterComplianceReport, error) { + var complianceReport v1alpha1.ClusterComplianceReport + _, _, err := scheme.Codecs.UniversalDecoder().Decode(bytes, nil, &complianceReport) + if err != nil { + return v1alpha1.ClusterComplianceReport{}, err + } + return complianceReport, nil +} diff --git a/itest/starboard/starboard_cli_test.go b/itest/starboard/starboard_cli_test.go index eb8995684..ae7362252 100644 --- a/itest/starboard/starboard_cli_test.go +++ b/itest/starboard/starboard_cli_test.go @@ -86,6 +86,34 @@ var _ = Describe("Starboard CLI", func() { "Scope": Equal(apiextensionsv1beta1.ClusterScoped), }), }), + "clustercompliancereports.aquasecurity.github.io": MatchFields(IgnoreExtras, Fields{ + "Spec": MatchFields(IgnoreExtras, Fields{ + "Group": Equal("aquasecurity.github.io"), + "Version": Equal("v1alpha1"), + "Names": Equal(apiextensionsv1beta1.CustomResourceDefinitionNames{ + Plural: "clustercompliancereports", + Singular: "clustercompliancereport", + ShortNames: []string{"compliance"}, + Kind: "ClusterComplianceReport", + ListKind: "ClusterComplianceReportList", + }), + "Scope": Equal(apiextensionsv1beta1.ClusterScoped), + }), + }), + "clustercompliancedetailreports.aquasecurity.github.io": MatchFields(IgnoreExtras, Fields{ + "Spec": MatchFields(IgnoreExtras, Fields{ + "Group": Equal("aquasecurity.github.io"), + "Version": Equal("v1alpha1"), + "Names": Equal(apiextensionsv1beta1.CustomResourceDefinitionNames{ + Plural: "clustercompliancedetailreports", + Singular: "clustercompliancedetailreport", + ShortNames: []string{"compliancedetail"}, + Kind: "ClusterComplianceDetailReport", + ListKind: "ClusterComplianceDetailReportList", + }), + "Scope": Equal(apiextensionsv1beta1.ClusterScoped), + }), + }), "configauditreports.aquasecurity.github.io": MatchFields(IgnoreExtras, Fields{ "Spec": MatchFields(IgnoreExtras, Fields{ "Group": Equal("aquasecurity.github.io"), @@ -173,8 +201,19 @@ var _ = Describe("Starboard CLI", func() { }, &corev1.ServiceAccount{}) Expect(err).ToNot(HaveOccurred()) }) + It("should deploy nsa report", func() { + nsaSpec := &v1alpha1.ClusterComplianceReport{} + err := kubeClient.Get(context.TODO(), types.NamespacedName{ + Name: "nsa", + }, nsaSpec) + Expect(err).ToNot(HaveOccurred()) + Expect(nsaSpec.Spec.Name == "nsa").To(BeTrue()) + Expect(nsaSpec.Spec.Description == "National Security Agency - Kubernetes Hardening Guidance").To(BeTrue()) + Expect(nsaSpec.Spec.Cron == "0 */3 * * *").To(BeTrue()) + Expect(nsaSpec.Spec.Version == "1.0").To(BeTrue()) + Expect(len(nsaSpec.Spec.Controls) == 27).To(BeTrue()) + }) }) - Describe("Command version", func() { It("should print the current version of the executable binary", func() { @@ -186,7 +225,6 @@ var _ = Describe("Starboard CLI", func() { Expect(err).ToNot(HaveOccurred()) Eventually(out).Should(Say("Starboard Version: {Version:dev Commit:none Date:unknown}")) }) - }) Describe("Command scan vulnerabilityreports", func() { @@ -1234,6 +1272,71 @@ var _ = Describe("Starboard CLI", func() { }) }) + Describe("Command get nsa compliance report", func() { + + It("should create nsa compliance report", func() { + // create ciskubebenchreports + err := cmd.Run(versionInfo, []string{ + "starboard", + "scan", "ciskubebenchreports", + "-v", starboardCLILogLevel, + }, GinkgoWriter, GinkgoWriter) + Expect(err).ToNot(HaveOccurred()) + + // create configauditreports + ctx := context.TODO() + object := helper.NewPod(). + WithRandomName("nginx-polaris"). + WithNamespace(testNamespace.Name). + WithContainer("nginx-container", "nginx:1.16"). + Build() + err = kubeClient.Create(ctx, object) + Expect(err).ToNot(HaveOccurred()) + + err = cmd.Run(versionInfo, []string{ + "starboard", + "scan", "configauditreports", "pod" + "/" + object.GetName(), + "--namespace", object.GetNamespace(), + "-v", starboardCLILogLevel, + }, GinkgoWriter, GinkgoWriter) + Expect(err).ToNot(HaveOccurred()) + + // get cluster compliance report + stdout := NewBuffer() + stderr := NewBuffer() + err = cmd.Run(versionInfo, []string{ + "starboard", "get", "clustercompliancereports", + "nsa", "--output", "yaml", + "-v", starboardCLILogLevel, + }, stdout, stderr) + Expect(err).ToNot(HaveOccurred()) + + var ccr v1alpha1.ClusterComplianceReport + err = yaml.Unmarshal(stdout.Contents(), &ccr) + Expect(err).ToNot(HaveOccurred()) + Expect(ccr.Status.Summary.PassCount > 0).To(BeTrue()) + Expect(ccr.Status.Summary.FailCount > 0).To(BeTrue()) + Expect(len(ccr.Status.ControlChecks) > 0).To(BeTrue()) + + // get cluster compliance detail report + stdout = NewBuffer() + stderr = NewBuffer() + err = cmd.Run(versionInfo, []string{ + "starboard", "get", "clustercompliancereports", + "nsa", "--output", "yaml", "--detail", + "-v", starboardCLILogLevel, + }, stdout, stderr) + Expect(err).ToNot(HaveOccurred()) + + var ccdr v1alpha1.ClusterComplianceDetailReport + err = yaml.Unmarshal(stdout.Contents(), &ccdr) + Expect(err).ToNot(HaveOccurred()) + Expect(ccdr.Report.Summary.PassCount > 0).To(BeTrue()) + Expect(ccdr.Report.Summary.FailCount > 0).To(BeTrue()) + Expect(len(ccdr.Report.ControlChecks) > 0).To(BeTrue()) + }) + }) + Describe("Command scan kubehunterreports", func() { BeforeEach(func() { diff --git a/pkg/apis/aquasecurity/v1alpha1/compliance_types.go b/pkg/apis/aquasecurity/v1alpha1/compliance_types.go index e62d4f581..726dd6d96 100644 --- a/pkg/apis/aquasecurity/v1alpha1/compliance_types.go +++ b/pkg/apis/aquasecurity/v1alpha1/compliance_types.go @@ -4,6 +4,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + ClusterComplianceReportCRName = "clustercompliancereports.aquasecurity.github.io" +) + type ClusterComplianceSummary struct { PassCount int `json:"passCount"` FailCount int `json:"failCount"` diff --git a/pkg/apis/aquasecurity/v1alpha1/compliancedetail_types.go b/pkg/apis/aquasecurity/v1alpha1/compliancedetail_types.go index 0b011e9fe..1f4f1247a 100644 --- a/pkg/apis/aquasecurity/v1alpha1/compliancedetail_types.go +++ b/pkg/apis/aquasecurity/v1alpha1/compliancedetail_types.go @@ -4,6 +4,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + ClusterComplianceDetailReportCRName = "clustercompliancedetailreports.aquasecurity.github.io" +) + // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/pkg/cmd/commands.go b/pkg/cmd/commands.go index 03fa6de1c..78316ebee 100644 --- a/pkg/cmd/commands.go +++ b/pkg/cmd/commands.go @@ -2,9 +2,12 @@ package cmd import ( "errors" + "fmt" "strings" "time" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" @@ -52,6 +55,17 @@ func WorkloadFromArgs(mapper meta.RESTMapper, namespace string, args []string) ( return } +func ComplianceNameFromArgs(args []string, suffix ...string) (types.NamespacedName, error) { + if len(args) < 1 { + return types.NamespacedName{}, fmt.Errorf("required compliance name not specified") + } + reportName := args[0] + if len(suffix) > 0 { + reportName = fmt.Sprintf("%s-%s", reportName, suffix[0]) + } + return types.NamespacedName{Name: reportName}, nil +} + const ( scanJobTimeoutFlagName = "scan-job-timeout" deleteScanJobFlagName = "delete-scan-job" diff --git a/pkg/cmd/get.go b/pkg/cmd/get.go index bc12b48e5..1e662cafe 100644 --- a/pkg/cmd/get.go +++ b/pkg/cmd/get.go @@ -15,6 +15,7 @@ func NewGetCmd(buildInfo starboard.BuildInfo, cf *genericclioptions.ConfigFlags, } getCmd.AddCommand(NewGetVulnerabilityReportsCmd(buildInfo.Executable, cf, outWriter)) getCmd.AddCommand(NewGetConfigAuditReportsCmd(buildInfo.Executable, cf, outWriter)) + getCmd.AddCommand(NewGetClusterComplianceReportsCmd(buildInfo.Executable, cf, outWriter)) getCmd.PersistentFlags().StringP("output", "o", "", "Output format. One of yaml|json") return getCmd diff --git a/pkg/cmd/get_clustercompliancereport.go b/pkg/cmd/get_clustercompliancereport.go new file mode 100644 index 000000000..9b42aa478 --- /dev/null +++ b/pkg/cmd/get_clustercompliancereport.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "context" + "fmt" + "github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1" + "github.com/aquasecurity/starboard/pkg/compliance" + "github.com/aquasecurity/starboard/pkg/starboard" + "github.com/spf13/cobra" + "io" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericclioptions" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func NewGetClusterComplianceReportsCmd(executable string, cf *genericclioptions.ConfigFlags, out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "clustercompliancereports (NAME)", + Aliases: []string{"clustercompliance"}, + Short: "Get cluster compliance reports", + Long: `Get cluster compliance report for pre-defined spec`, + Example: fmt.Sprintf(` # Get cluster compliance report for specifc spec in JSON output format + %[1]s get clustercompliancereports nsa -o json + + # Get compliance detail report for control checks failure in JSON output format + %[1]s get clustercompliancereports nsa -o json --detail`, executable), + RunE: func(cmd *cobra.Command, args []string) error { + logger := ctrl.Log.WithName("reconciler").WithName("clustercompliancereport") + ctx := context.Background() + scheme := starboard.NewScheme() + kubeConfig, err := cf.ToRESTConfig() + if err != nil { + return fmt.Errorf("failed to create kubeConfig: %w", err) + } + + kubeClient, err := client.New(kubeConfig, client.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + namespaceName, err := ComplianceNameFromArgs(args) + if err != nil { + return err + } + + var report v1alpha1.ClusterComplianceReport + err = GetComplianceReport(ctx, kubeClient, namespaceName, out, &report) + if err != nil { + return err + } + // generate compliance and compliance failure detail reports + complianceMgr := compliance.NewMgr(kubeClient, logger) + err = complianceMgr.GenerateComplianceReport(ctx, report.Spec) + if err != nil { + return fmt.Errorf("failed to generate report: %w", err) + } + + format := cmd.Flag("output").Value.String() + printer, err := genericclioptions.NewPrintFlags(""). + WithTypeSetter(scheme). + WithDefaultOutput(format). + ToPrinter() + if err != nil { + return fmt.Errorf("faild to create printer: %w", err) + } + + detail, err := cmd.Flags().GetBool("detail") + if err != nil { + return fmt.Errorf("detail flag is not set correctly, check flag usage: %w", err) + } + if !detail { + var complianceReport v1alpha1.ClusterComplianceReport + err := GetComplianceReport(ctx, kubeClient, namespaceName, out, &complianceReport) + if err != nil { + return err + } + if err := printer.PrintObj(&complianceReport, out); err != nil { + return fmt.Errorf("print compliance reports: %w", err) + } + return nil + } + + detailNamespaceName, err := ComplianceNameFromArgs(args, "details") + if err != nil { + return err + } + var complianceDetailReport v1alpha1.ClusterComplianceDetailReport + err = GetComplianceReport(ctx, kubeClient, detailNamespaceName, out, &complianceDetailReport) + if err != nil { + return err + } + if err := printer.PrintObj(&complianceDetailReport, out); err != nil { + return fmt.Errorf("print compliance reports: %w", err) + } + return nil + }, + } + cmd.PersistentFlags().BoolP("detail", "d", false, "Get compliance detail report for control checks failure") + return cmd +} + +func GetComplianceReport(ctx context.Context, client client.Client, namespaceName types.NamespacedName, out io.Writer, report client.Object) error { + err := client.Get(ctx, namespaceName, report) + if err != nil { + if errors.IsNotFound(err) { + fmt.Fprintf(out, "No complaince reports found with name: %s .\n", namespaceName.Name) + return err + } + return fmt.Errorf("failed getting report: %w", err) + } + return nil +} diff --git a/pkg/cmd/installer.go b/pkg/cmd/installer.go index 480ed49fb..f59d10450 100644 --- a/pkg/cmd/installer.go +++ b/pkg/cmd/installer.go @@ -5,6 +5,8 @@ import ( "fmt" "time" + "k8s.io/apimachinery/pkg/types" + embedded "github.com/aquasecurity/starboard" "github.com/aquasecurity/starboard/pkg/apis/aquasecurity/v1alpha1" "github.com/aquasecurity/starboard/pkg/plugin" @@ -198,9 +200,34 @@ func (m *Installer) Install(ctx context.Context) error { if err != nil { return err } + clusterComplianceReportsCRD, err := embedded.GetClusterComplianceReportsCRD() + if err != nil { + return err + } + err = m.createOrUpdateCRD(ctx, &clusterComplianceReportsCRD) + if err != nil { + return err + } + clusterComplianceDetailReportsCRD, err := embedded.GetClusterComplianceDetailReportsCRD() + if err != nil { + return err + } + err = m.createOrUpdateCRD(ctx, &clusterComplianceDetailReportsCRD) + if err != nil { + return err + } // TODO We should wait for CRD statuses and make sure that the names were accepted + // compliance report + clusterComplianceReportSpec, err := embedded.GetNSASpecV10() + if err != nil { + return err + } + err = m.createOrUpdateComplianceSpec(ctx, clusterComplianceReportSpec) + if err != nil { + return err + } err = m.createNamespaceIfNotFound(ctx, namespace) if err != nil { return err @@ -387,6 +414,22 @@ func (m *Installer) createOrUpdateCRD(ctx context.Context, crd *ext.CustomResour return } +func (m *Installer) createOrUpdateComplianceSpec(ctx context.Context, spec v1alpha1.ClusterComplianceReport) error { + namespaceName := types.NamespacedName{Name: spec.Spec.Name} + err := m.client.Get(ctx, namespaceName, &spec) + switch { + case err == nil: + klog.V(3).Infof("Updating compliance spec %q", spec.Spec.Name) + deepCopy := spec.DeepCopy() + deepCopy.Spec = spec.Spec + return m.client.Update(ctx, deepCopy) + case errors.IsNotFound(err): + klog.V(3).Infof("Creating compliance spec %q", spec.Spec.Name) + return m.client.Create(ctx, &spec) + } + return nil +} + func (m *Installer) deleteCRD(ctx context.Context, name string) (err error) { klog.V(3).Infof("Deleting CRD %q", name) err = m.clientsetext.CustomResourceDefinitions().Delete(ctx, name, metav1.DeleteOptions{}) @@ -421,6 +464,14 @@ func (m *Installer) Uninstall(ctx context.Context) error { if err != nil { return err } + err = m.deleteCRD(ctx, v1alpha1.ClusterComplianceReportCRName) + if err != nil { + return err + } + err = m.deleteCRD(ctx, v1alpha1.ClusterComplianceDetailReportCRName) + if err != nil { + return err + } err = m.cleanupRBAC(ctx) if err != nil { return err diff --git a/pkg/compliance/clustercompliancereport.go b/pkg/compliance/clustercompliancereport.go index b5cf34299..97d3f5185 100644 --- a/pkg/compliance/clustercompliancereport.go +++ b/pkg/compliance/clustercompliancereport.go @@ -60,12 +60,7 @@ func (r *ClusterComplianceReportReconciler) generateComplianceReport(ctx context return fmt.Errorf("failed to check report cron expression %w", err) } if utils.DurationExceeded(durationToNextGeneration) { - updatedReport, err := r.Mgr.GenerateComplianceReport(ctx, report.Spec) - if err != nil { - return fmt.Errorf("failed to generate new report %w", err) - } - // update compliance report status - return r.Status().Update(ctx, updatedReport) + return r.Mgr.GenerateComplianceReport(ctx, report.Spec) } log.V(1).Info("RequeueAfter", "durationToNextGeneration", durationToNextGeneration) ctrlResult.RequeueAfter = durationToNextGeneration diff --git a/pkg/compliance/io.go b/pkg/compliance/io.go index 7d1d7c9bd..c3b5f2ceb 100644 --- a/pkg/compliance/io.go +++ b/pkg/compliance/io.go @@ -20,7 +20,7 @@ const ( ) type Mgr interface { - GenerateComplianceReport(ctx context.Context, spec v1alpha1.ReportSpec) (*v1alpha1.ClusterComplianceReport, error) + GenerateComplianceReport(ctx context.Context, spec v1alpha1.ReportSpec) error } func NewMgr(client client.Client, log logr.Logger) Mgr { @@ -47,7 +47,7 @@ type specDataMapping struct { controlIdResources map[string][]string } -func (w *cm) GenerateComplianceReport(ctx context.Context, spec v1alpha1.ReportSpec) (*v1alpha1.ClusterComplianceReport, error) { +func (w *cm) GenerateComplianceReport(ctx context.Context, spec v1alpha1.ReportSpec) error { // map specs to key/value map for easy processing smd := w.populateSpecDataToMaps(spec) // map compliance scanner to resource data @@ -55,19 +55,24 @@ func (w *cm) GenerateComplianceReport(ctx context.Context, spec v1alpha1.ReportS // organized data by check id and it aggregated results checkIdsToResults, err := w.checkIdsToResults(scannerResourceMap) if err != nil { - return nil, err + return err } // map scanner checks results to control check results controlChecks := w.controlChecksByScannerChecks(smd, checkIdsToResults) // find summary totals st := w.getTotals(controlChecks) - //publish compliance details report + //create cluster compliance details report err = w.createComplianceDetailReport(ctx, spec, smd, checkIdsToResults, st) if err != nil { - return nil, err + return err } - //generate compliance details report - return w.createComplianceReport(ctx, spec, st, controlChecks) + //generate cluster compliance report + updatedReport, err := w.createComplianceReport(ctx, spec, st, controlChecks) + if err != nil { + return err + } + // update compliance report status + return w.client.Status().Update(ctx, updatedReport) }