diff --git a/cmd/polaris/audit.go b/cmd/polaris/audit.go index 0b8d530c9..f4b96ce42 100644 --- a/cmd/polaris/audit.go +++ b/cmd/polaris/audit.go @@ -35,6 +35,7 @@ var minScore int var auditOutputURL string var auditOutputFile string var auditOutputFormat string +var resourceToAudit string func init() { rootCmd.AddCommand(auditCmd) @@ -45,6 +46,7 @@ func init() { auditCmd.PersistentFlags().StringVar(&auditOutputFile, "output-file", "", "Destination file for audit results.") auditCmd.PersistentFlags().StringVarP(&auditOutputFormat, "format", "f", "json", "Output format for results - json, yaml, or score.") auditCmd.PersistentFlags().StringVar(&displayName, "display-name", "", "An optional identifier for the audit.") + auditCmd.PersistentFlags().StringVar(&resourceToAudit, "resource", "", "Audit a specific resource, in the format namespace/kind/version/name, e.g. nginx-ingress/Deployment.apps/v1/default-backend.") } var auditCmd = &cobra.Command{ @@ -56,7 +58,7 @@ var auditCmd = &cobra.Command{ config.DisplayName = displayName } - auditData := runAndReportAudit(config, auditPath, auditOutputFile, auditOutputURL, auditOutputFormat) + auditData := runAndReportAudit(config, auditPath, resourceToAudit, auditOutputFile, auditOutputURL, auditOutputFormat) summary := auditData.GetSummary() score := summary.GetScore() @@ -70,9 +72,9 @@ var auditCmd = &cobra.Command{ }, } -func runAndReportAudit(c conf.Configuration, auditPath string, outputFile string, outputURL string, outputFormat string) validator.AuditData { +func runAndReportAudit(c conf.Configuration, auditPath, workload, outputFile, outputURL, outputFormat string) validator.AuditData { // Create a kubernetes client resource provider - k, err := kube.CreateResourceProvider(auditPath) + k, err := kube.CreateResourceProvider(auditPath, workload) if err != nil { logrus.Errorf("Error fetching Kubernetes resources %v", err) os.Exit(1) diff --git a/docs/usage.md b/docs/usage.md index 7774a0ec1..89d55681b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -115,6 +115,11 @@ brew install reactiveops/tap/polaris polaris dashboard --port 8080 ``` +You can also point the dashboard to the local filesystem, instead of a live cluster: +```bash +polaris dashboard --port 8080 --audit-path=./deploy/ +``` + ## Webhook ### kubectl ```bash @@ -145,13 +150,19 @@ polaris audit --format score # 92 ``` -Both the dashboard and audits can run against a local directory or YAML file -rather than a cluster: +Audits can run against a local directory or YAML file rather than a cluster: ```bash polaris audit --audit-path ./deploy/ + +# or to use STDIN cat pod.yaml | polaris audit --audit-path - ``` +You can also run the audit on a single resource instead of the entire cluster: +```bash +polaris audit --resource "nginx-ingress/Deployment.apps/v1/default-backend" +``` + #### Running with CI/CD You can integrate Polaris into CI/CD for repositories containing infrastructure-as-code. For example, to fail if polaris detects *any* danger-level issues, or if the score drops below 90%: @@ -207,6 +218,8 @@ webhook # audit flags --audit-path string If specified, audits one or more YAML files instead of a cluster +--resource string + If specified, audit a specific resource, in the format namespace/kind/version/name, e.g. nginx-ingress/Deployment.apps/v1/default-backend --display-name string An optional identifier for the audit --format string diff --git a/pkg/dashboard/dashboard.go b/pkg/dashboard/dashboard.go index a4247fd00..eb1f16353 100644 --- a/pkg/dashboard/dashboard.go +++ b/pkg/dashboard/dashboard.go @@ -184,7 +184,7 @@ func GetRouter(c config.Configuration, auditPath string, port int, basePath stri router.HandleFunc("/results.json", func(w http.ResponseWriter, r *http.Request) { adjustedConf := getConfigForQuery(c, r.URL.Query()) if auditData == nil { - k, err := kube.CreateResourceProvider(auditPath) + k, err := kube.CreateResourceProvider(auditPath, "") if err != nil { logrus.Errorf("Error fetching Kubernetes resources %v", err) http.Error(w, "Error fetching Kubernetes resources", http.StatusInternalServerError) @@ -217,7 +217,7 @@ func GetRouter(c config.Configuration, auditPath string, port int, basePath stri adjustedConf := getConfigForQuery(c, r.URL.Query()) if auditData == nil { - k, err := kube.CreateResourceProvider(auditPath) + k, err := kube.CreateResourceProvider(auditPath, "") if err != nil { logrus.Errorf("Error fetching Kubernetes resources %v", err) http.Error(w, "Error fetching Kubernetes resources", http.StatusInternalServerError) diff --git a/pkg/kube/resources.go b/pkg/kube/resources.go index 88c311d80..8112910ce 100644 --- a/pkg/kube/resources.go +++ b/pkg/kube/resources.go @@ -2,6 +2,7 @@ package kube import ( "bytes" + "fmt" "io" "io/ioutil" "os" @@ -11,7 +12,6 @@ import ( "time" "github.com/sirupsen/logrus" - "gopkg.in/yaml.v3" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -42,13 +42,77 @@ type k8sResource struct { var podSpecFields = []string{"jobTemplate", "spec", "template"} // CreateResourceProvider returns a new ResourceProvider object to interact with k8s resources -func CreateResourceProvider(directory string) (*ResourceProvider, error) { +func CreateResourceProvider(directory, workload string) (*ResourceProvider, error) { + if workload != "" { + return CreateResourceProviderFromWorkload(workload) + } if directory != "" { return CreateResourceProviderFromPath(directory) } return CreateResourceProviderFromCluster() } +// CreateResourceProviderFromWorkload creates a new ResourceProvider that just contains one workload +func CreateResourceProviderFromWorkload(workload string) (*ResourceProvider, error) { + kubeConf, configError := config.GetConfig() + if configError != nil { + logrus.Errorf("Error fetching KubeConfig: %v", configError) + return nil, configError + } + kube, err := kubernetes.NewForConfig(kubeConf) + if err != nil { + logrus.Errorf("Error creating Kubernetes client: %v", err) + return nil, err + } + serverVersion, err := kube.Discovery().ServerVersion() + if err != nil { + logrus.Errorf("Error fetching Cluster API version: %v", err) + return nil, err + } + resources := ResourceProvider{ + ServerVersion: serverVersion.Major + "." + serverVersion.Minor, + SourceType: "Workload", + SourceName: workload, + CreationTime: time.Now(), + Nodes: []corev1.Node{}, + Namespaces: []corev1.Namespace{}, + } + + parts := strings.Split(workload, "/") + if len(parts) != 4 { + return nil, fmt.Errorf("Invalid workload identifier %s. Should be in format namespace/kind/version/name, e.g. nginx-ingress/Deployment.apps/v1/default-backend", workload) + } + namespace := parts[0] + kind := parts[1] + version := parts[2] + name := parts[3] + + dynamicInterface, err := dynamic.NewForConfig(kubeConf) + if err != nil { + logrus.Errorf("Error connecting to dynamic interface: %v", err) + return nil, err + } + groupResources, err := restmapper.GetAPIGroupResources(kube.Discovery()) + if err != nil { + logrus.Errorf("Error getting API Group resources: %v", err) + return nil, err + } + restMapper := restmapper.NewDiscoveryRESTMapper(groupResources) + obj, err := getObject(namespace, kind, version, name, &dynamicInterface, &restMapper) + if err != nil { + logrus.Errorf("Could not find workload %s: %v", workload, err) + return nil, err + } + workloadObj, err := NewGenericWorkloadFromUnstructured(kind, obj) + if err != nil { + logrus.Errorf("Could not parse workload %s: %v", workload, err) + return nil, err + } + + resources.Controllers = []GenericWorkload{workloadObj} + return &resources, nil +} + // CreateResourceProviderFromPath returns a new ResourceProvider using the YAML files in a directory func CreateResourceProviderFromPath(directory string) (*ResourceProvider, error) { resources := ResourceProvider{ @@ -201,19 +265,6 @@ func deduplicateControllers(inputControllers []GenericWorkload) []GenericWorkloa return results } -// GetPodSpec looks inside arbitrary YAML for a PodSpec -func GetPodSpec(yaml map[string]interface{}) interface{} { - for _, child := range podSpecFields { - if childYaml, ok := yaml[child]; ok { - return GetPodSpec(childYaml.(map[string]interface{})) - } - } - if _, ok := yaml["containers"]; ok { - return yaml - } - return nil -} - func addResourcesFromReader(reader io.Reader, resources *ResourceProvider) error { contents, err := ioutil.ReadAll(reader) if err != nil { @@ -241,40 +292,6 @@ func addResourcesFromYaml(contents string, resources *ResourceProvider) error { return nil } -// GetWorkloadFromBytes parses a GenericWorkload -func GetWorkloadFromBytes(contentBytes []byte) (*GenericWorkload, error) { - yamlNode := make(map[string]interface{}) - err := yaml.Unmarshal(contentBytes, &yamlNode) - if err != nil { - logrus.Errorf("Invalid YAML: %s", string(contentBytes)) - return nil, err - } - finalDoc := make(map[string]interface{}) - finalDoc["metadata"] = yamlNode["metadata"] - finalDoc["apiVersion"] = "v1" - finalDoc["kind"] = "Pod" - podSpec := GetPodSpec(yamlNode) - if podSpec == nil { - return nil, nil - } - finalDoc["spec"] = podSpec - marshaledYaml, err := yaml.Marshal(finalDoc) - if err != nil { - logrus.Errorf("Could not marshal yaml: %v", err) - return nil, err - } - decoder := k8sYaml.NewYAMLOrJSONDecoder(bytes.NewReader(marshaledYaml), 1000) - pod := corev1.Pod{} - err = decoder.Decode(&pod) - newController, err := NewGenericWorkloadFromPod(pod, yamlNode) - - if err != nil { - return nil, err - } - newController.Kind = yamlNode["kind"].(string) - return &newController, nil -} - func addResourceFromString(contents string, resources *ResourceProvider) error { contentBytes := []byte(contents) decoder := k8sYaml.NewYAMLOrJSONDecoder(bytes.NewReader(contentBytes), 1000) diff --git a/pkg/kube/workload.go b/pkg/kube/workload.go index d97f9361a..6c268f53f 100644 --- a/pkg/kube/workload.go +++ b/pkg/kube/workload.go @@ -1,15 +1,18 @@ package kube import ( + "bytes" "encoding/json" "fmt" "github.com/sirupsen/logrus" + "gopkg.in/yaml.v3" kubeAPICoreV1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/meta" kubeAPIMetaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + k8sYaml "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/dynamic" ) @@ -21,12 +24,51 @@ type GenericWorkload struct { OriginalObjectJSON []byte } +// NewGenericWorkloadFromUnstructured creates a workload from an unstructured.Unstructured +func NewGenericWorkloadFromUnstructured(kind string, unst *unstructured.Unstructured) (GenericWorkload, error) { + workload := GenericWorkload{ + Kind: kind, + } + + objMeta, err := meta.Accessor(unst) + if err != nil { + return workload, err + } + workload.ObjectMeta = objMeta + + b, err := json.Marshal(unst) + if err != nil { + return workload, err + } + workload.OriginalObjectJSON = b + + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + if err != nil { + return workload, err + } + podSpecMap := GetPodSpec(m) + b, err = json.Marshal(podSpecMap) + if err != nil { + return workload, err + } + podSpec := kubeAPICoreV1.PodSpec{} + err = json.Unmarshal(b, &podSpec) + if err != nil { + return workload, err + } + workload.PodSpec = podSpec + + return workload, nil +} + // NewGenericWorkloadFromPod builds a new workload for a given Pod without looking at parents func NewGenericWorkloadFromPod(podResource kubeAPICoreV1.Pod, originalObject interface{}) (GenericWorkload, error) { - workload := GenericWorkload{} - workload.PodSpec = podResource.Spec - workload.ObjectMeta = podResource.ObjectMeta.GetObjectMeta() - workload.Kind = "Pod" + workload := GenericWorkload{ + Kind: "Pod", + PodSpec: podResource.Spec, + ObjectMeta: podResource.ObjectMeta.GetObjectMeta(), + } if originalObject != nil { bytes, err := json.Marshal(originalObject) if err != nil { @@ -127,3 +169,60 @@ func cacheAllObjectsOfKind(apiVersion, kind string, dynamicClient *dynamic.Inter } return nil } + +func getObject(namespace, kind, version, name string, dynamicClient *dynamic.Interface, restMapper *meta.RESTMapper) (*unstructured.Unstructured, error) { + fqKind := schema.ParseGroupKind(kind) + mapping, err := (*restMapper).RESTMapping(fqKind, version) + if err != nil { + return nil, err + } + object, err := (*dynamicClient).Resource(mapping.Resource).Namespace(namespace).Get(name, kubeAPIMetaV1.GetOptions{}) + return object, err +} + +// GetPodSpec looks inside arbitrary YAML for a PodSpec +func GetPodSpec(yaml map[string]interface{}) interface{} { + for _, child := range podSpecFields { + if childYaml, ok := yaml[child]; ok { + return GetPodSpec(childYaml.(map[string]interface{})) + } + } + if _, ok := yaml["containers"]; ok { + return yaml + } + return nil +} + +// GetWorkloadFromBytes parses a GenericWorkload +func GetWorkloadFromBytes(contentBytes []byte) (*GenericWorkload, error) { + yamlNode := make(map[string]interface{}) + err := yaml.Unmarshal(contentBytes, &yamlNode) + if err != nil { + logrus.Errorf("Invalid YAML: %s", string(contentBytes)) + return nil, err + } + finalDoc := make(map[string]interface{}) + finalDoc["metadata"] = yamlNode["metadata"] + finalDoc["apiVersion"] = "v1" + finalDoc["kind"] = "Pod" + podSpec := GetPodSpec(yamlNode) + if podSpec == nil { + return nil, nil + } + finalDoc["spec"] = podSpec + marshaledYaml, err := yaml.Marshal(finalDoc) + if err != nil { + logrus.Errorf("Could not marshal yaml: %v", err) + return nil, err + } + decoder := k8sYaml.NewYAMLOrJSONDecoder(bytes.NewReader(marshaledYaml), 1000) + pod := kubeAPICoreV1.Pod{} + err = decoder.Decode(&pod) + newController, err := NewGenericWorkloadFromPod(pod, yamlNode) + + if err != nil { + return nil, err + } + newController.Kind = yamlNode["kind"].(string) + return &newController, nil +}