Skip to content

Commit

Permalink
Feat: Discover unused RoleBindings (#362)
Browse files Browse the repository at this point in the history
* Adding basic structure, need to write the logic

* #1 validate role exists

* validating role ref exists, and adding basic tests

* handle service accounts

* more tests

* add to all arg

* more tests

* delete.go

* multi.go

* readme

* refactor

* import order

* -

* Move convertNamesToPresenseMap to kor.go, and create checkRoleReferences function

* remove rb

* naming
  • Loading branch information
nati-elmaliach authored Oct 13, 2024
1 parent f0643f0 commit 673c21e
Show file tree
Hide file tree
Showing 9 changed files with 428 additions and 0 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Kor is a tool to discover unused Kubernetes resources. Currently, Kor can identi
- DaemonSets
- StorageClasses
- NetworkPolicies
- RoleBindings

![Kor Screenshot](/images/show_reason_screenshot.png)

Expand Down Expand Up @@ -110,6 +111,7 @@ Kor provides various subcommands to identify and list unused resources. The avai
- `statefulset` - Gets unused StatefulSets for the specified namespace or all namespaces.
- `role` - Gets unused Roles for the specified namespace or all namespaces.
- `clusterrole` - Gets unused ClusterRoles for the specified namespace or all namespaces (namespace refers to RoleBinding).
- `rolebinding` - Gets unused RoleBindings for the specified namespace or all namespaces.
- `hpa` - Gets unused HPAs for the specified namespace or all namespaces.
- `pod` - Gets unused Pods for the specified namespace or all namespaces.
- `pvc` - Gets unused PVCs for the specified namespace or all namespaces.
Expand Down Expand Up @@ -172,6 +174,7 @@ kor [subcommand] --help
| StatefulSets | Statefulsets with no Replicas | |
| Roles | Roles not used in roleBinding | |
| ClusterRoles | ClusterRoles not used in roleBinding or clusterRoleBinding<br/>ClusterRoles not used in ClusterRole aggregation | |
| RoleBindings | RoleBindings referencing invalid Role, ClusterRole, or ServiceAccounts | |
| PVCs | PVCs not used in Pods | |
| Ingresses | Ingresses not pointing at any Service | |
| Hpas | HPAs not used in Deployments<br/> HPAs not used in StatefulSets | |
Expand Down
31 changes: 31 additions & 0 deletions cmd/kor/rolebindings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package kor

import (
"fmt"

"github.com/spf13/cobra"

"github.com/yonahd/kor/pkg/kor"
"github.com/yonahd/kor/pkg/utils"
)

var roleBindingCmd = &cobra.Command{
Use: "rolebinding",
Aliases: []string{"rolebindings"},
Short: "Gets unused role bindings",
Args: cobra.ExactArgs(0),
Run: func(cmd *cobra.Command, args []string) {
clientset := kor.GetKubeClient(kubeconfig)

if response, err := kor.GetUnusedRoleBindings(filterOptions, clientset, outputFormat, opts); err != nil {
fmt.Println(err)
} else {
utils.PrintLogo(outputFormat)
fmt.Println(response)
}
},
}

func init() {
rootCmd.AddCommand(roleBindingCmd)
}
15 changes: 15 additions & 0 deletions pkg/kor/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,19 @@ func getUnusedNetworkPolicies(clientset kubernetes.Interface, namespace string,
return namespaceNetpolDiff
}

func getUnusedRoleBindings(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ResourceDiff {
roleBindingDiff, err := processNamespaceRoleBindings(clientset, namespace, filterOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to get %s namespace %s: %v\n", "RoleBindings", namespace, err)
}

namespaceRoleBindingDiff := ResourceDiff{
"RoleBinding",
roleBindingDiff,
}
return namespaceRoleBindingDiff
}

func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts common.Opts) (string, error) {
resources := make(map[string]map[string][]ResourceInfo)
for _, namespace := range filterOpts.Namespaces(clientset) {
Expand All @@ -286,6 +299,7 @@ func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.In
resources[namespace]["ReplicaSet"] = getUnusedReplicaSets(clientset, namespace, filterOpts).diff
resources[namespace]["DaemonSet"] = getUnusedDaemonSets(clientset, namespace, filterOpts).diff
resources[namespace]["NetworkPolicy"] = getUnusedNetworkPolicies(clientset, namespace, filterOpts).diff
resources[namespace]["RoleBinding"] = getUnusedRoleBindings(clientset, namespace, filterOpts).diff
case "resource":
appendResources(resources, "ConfigMap", namespace, getUnusedCMs(clientset, namespace, filterOpts).diff)
appendResources(resources, "Service", namespace, getUnusedSVCs(clientset, namespace, filterOpts).diff)
Expand All @@ -303,6 +317,7 @@ func GetUnusedAllNamespaced(filterOpts *filters.Options, clientset kubernetes.In
appendResources(resources, "ReplicaSet", namespace, getUnusedReplicaSets(clientset, namespace, filterOpts).diff)
appendResources(resources, "DaemonSet", namespace, getUnusedDaemonSets(clientset, namespace, filterOpts).diff)
appendResources(resources, "NetworkPolicy", namespace, getUnusedNetworkPolicies(clientset, namespace, filterOpts).diff)
appendResources(resources, "RoleBinding", namespace, getUnusedRoleBindings(clientset, namespace, filterOpts).diff)
}
}

Expand Down
7 changes: 7 additions & 0 deletions pkg/kor/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ func DeleteResourceCmd() map[string]func(clientset kubernetes.Interface, namespa
"NetworkPolicy": func(clientset kubernetes.Interface, namespace, name string) error {
return clientset.NetworkingV1().NetworkPolicies(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
},
"RoleBinding": func(clientset kubernetes.Interface, namespace, name string) error {
return clientset.RbacV1().RoleBindings(namespace).Delete(context.TODO(), name, metav1.DeleteOptions{})
},
}

return deleteResourceApiMap
Expand Down Expand Up @@ -170,6 +173,8 @@ func updateResource(clientset kubernetes.Interface, namespace, resourceType stri
return clientset.StorageV1().StorageClasses().Update(context.TODO(), resource.(*storagev1.StorageClass), metav1.UpdateOptions{})
case "NetworkPolicy":
return clientset.NetworkingV1().NetworkPolicies(namespace).Update(context.TODO(), resource.(*networkingv1.NetworkPolicy), metav1.UpdateOptions{})
case "RoleBinding":
return clientset.RbacV1().RoleBindings(namespace).Update(context.TODO(), resource.(*rbacv1.RoleBinding), metav1.UpdateOptions{})
}
return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
}
Expand Down Expand Up @@ -214,6 +219,8 @@ func getResource(clientset kubernetes.Interface, namespace, resourceType, resour
return clientset.StorageV1().StorageClasses().Get(context.TODO(), resourceName, metav1.GetOptions{})
case "NetworkPolicy":
return clientset.NetworkingV1().NetworkPolicies(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
case "RoleBinding":
return clientset.RbacV1().RoleBindings(namespace).Get(context.TODO(), resourceName, metav1.GetOptions{})
}
return nil, fmt.Errorf("resource type '%s' is not supported", resourceType)
}
Expand Down
34 changes: 34 additions & 0 deletions pkg/kor/exceptions/rolebindings/rolebindings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"exceptionRoleBindings": [
{
"Namespace": "kube-public",
"ResourceName": "kubeadm:bootstrap-signer-clusterinfo"
},
{
"Namespace": "kube-public",
"ResourceName": "system:controller:bootstrap-signer"
},
{
"Namespace": "kube-system",
"ResourceName": "kube-proxy"
},
{
"Namespace": "kube-system",
"ResourceName": "kubeadm:kubelet-config"
},
{
"Namespace": "kube-system",
"ResourceName": "kubeadm:nodes-kubeadm-config"
},
{
"Namespace": "kube-system",
"ResourceName": "system::*",
"MatchRegex": true
},
{
"Namespace": "kube-system",
"ResourceName": "system:controller:*",
"MatchRegex": true
}
]
}
15 changes: 15 additions & 0 deletions pkg/kor/kor.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Config struct {
ExceptionStorageClasses []ExceptionResource `json:"exceptionStorageClasses"`
ExceptionJobs []ExceptionResource `json:"exceptionJobs"`
ExceptionPdbs []ExceptionResource `json:"exceptionPdbs"`
ExceptionRoleBindings []ExceptionResource `json:"exceptionRoleBindings"`
// Add other configurations if needed
}

Expand Down Expand Up @@ -190,3 +191,17 @@ func resourceInfoContains(slice []ResourceInfo, item string) bool {
}
return false
}

// Convert a slice of names into a map for fast lookup
func convertNamesToPresenseMap(names []string, _ []string, err error) (map[string]bool, error) {
if err != nil {
return nil, err
}

namesMap := make(map[string]bool)
for _, n := range names {
namesMap[n] = true
}

return namesMap, nil
}
2 changes: 2 additions & 0 deletions pkg/kor/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ func retrieveNamespaceDiffs(clientset kubernetes.Interface, namespace string, re
diffResult = getUnusedDaemonSets(clientset, namespace, filterOpts)
case "netpol", "networkpolicy", "networkpolicies":
diffResult = getUnusedNetworkPolicies(clientset, namespace, filterOpts)
case "rolebinding", "rolebindings":
diffResult = getUnusedNetworkPolicies(clientset, namespace, filterOpts)
default:
fmt.Printf("resource type %q is not supported\n", resource)
}
Expand Down
158 changes: 158 additions & 0 deletions pkg/kor/rolebindings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package kor

import (
"bytes"
"context"
_ "embed"
"encoding/json"
"fmt"
"os"

v1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"

"github.com/yonahd/kor/pkg/common"
"github.com/yonahd/kor/pkg/filters"
)

//go:embed exceptions/rolebindings/rolebindings.json
var roleBindingsConfig []byte

// Filter out subjects base on Kind, can be later used for User and Group
func filterSubjects(subjects []v1.Subject, kind string) []v1.Subject {
var serviceAccountSubjects []v1.Subject
for _, subject := range subjects {
if subject.Kind == kind {
serviceAccountSubjects = append(serviceAccountSubjects, subject)
}
}
return serviceAccountSubjects
}

// Check if any valid service accounts exist in the RoleBinding
func isUsingValidServiceAccount(serviceAccounts []v1.Subject, serviceAccountNames map[string]bool) bool {
for _, sa := range serviceAccounts {
if serviceAccountNames[sa.Name] {
return true
}
}
return false
}

func validateRoleReference(rb v1.RoleBinding, roleNames, clusterRoleNames map[string]bool) *ResourceInfo {
if rb.RoleRef.Kind == "Role" && !roleNames[rb.RoleRef.Name] {
return &ResourceInfo{Name: rb.Name, Reason: "RoleBinding references a non-existing Role"}
}

if rb.RoleRef.Kind == "ClusterRole" && !clusterRoleNames[rb.RoleRef.Name] {
return &ResourceInfo{Name: rb.Name, Reason: "RoleBinding references a non-existing ClusterRole"}
}

return nil
}

func processNamespaceRoleBindings(clientset kubernetes.Interface, namespace string, filterOpts *filters.Options) ([]ResourceInfo, error) {
roleBindingsList, err := clientset.RbacV1().RoleBindings(namespace).List(context.TODO(), metav1.ListOptions{LabelSelector: filterOpts.IncludeLabels})
if err != nil {
return nil, err
}

roleNames, err := convertNamesToPresenseMap(retrieveRoleNames(clientset, namespace, filterOpts))
if err != nil {
return nil, err
}

clusterRoleNames, err := convertNamesToPresenseMap(retrieveClusterRoleNames(clientset, filterOpts))
if err != nil {
return nil, err
}

serviceAccountNames, err := convertNamesToPresenseMap(retrieveServiceAccountNames(clientset, namespace, filterOpts))
if err != nil {
return nil, err
}

config, err := unmarshalConfig(roleBindingsConfig)
if err != nil {
return nil, err
}

var unusedRoleBindingNames []ResourceInfo

for _, rb := range roleBindingsList.Items {
if pass, _ := filter.SetObject(&rb).Run(filterOpts); pass {
continue
}

if exceptionFound, err := isResourceException(rb.Name, rb.Namespace, config.ExceptionRoleBindings); err != nil {
return nil, err
} else if exceptionFound {
continue
}

roleReferenceIssue := validateRoleReference(rb, roleNames, clusterRoleNames)
if roleReferenceIssue != nil {
unusedRoleBindingNames = append(unusedRoleBindingNames, *roleReferenceIssue)
continue
}

serviceAccountSubjects := filterSubjects(rb.Subjects, "ServiceAccount")

// If other kinds (Users/Groups) are used, we assume they exists for now
if len(serviceAccountSubjects) != len(rb.Subjects) {
continue
}

// Check if RoleBinding uses a valid service account
if !isUsingValidServiceAccount(serviceAccountSubjects, serviceAccountNames) {
unusedRoleBindingNames = append(unusedRoleBindingNames, ResourceInfo{Name: rb.Name, Reason: "RoleBinding references a non-existing ServiceAccount"})
}
}

return unusedRoleBindingNames, nil
}

func GetUnusedRoleBindings(filterOpts *filters.Options, clientset kubernetes.Interface, outputFormat string, opts common.Opts) (string, error) {
resources := make(map[string]map[string][]ResourceInfo)
for _, namespace := range filterOpts.Namespaces(clientset) {
diff, err := processNamespaceRoleBindings(clientset, namespace, filterOpts)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to process namespace %s: %v\n", namespace, err)
continue
}

if opts.DeleteFlag {
if diff, err = DeleteResource(diff, clientset, namespace, "RoleBinding", opts.NoInteractive); err != nil {
fmt.Fprintf(os.Stderr, "Failed to delete RoleBinding %s in namespace %s: %v\n", diff, namespace, err)
}
}

switch opts.GroupBy {
case "namespace":
resources[namespace] = make(map[string][]ResourceInfo)
resources[namespace]["RoleBinding"] = diff
case "resource":
appendResources(resources, "RoleBinding", namespace, diff)
}
}

var outputBuffer bytes.Buffer
var jsonResponse []byte
switch outputFormat {
case "table":
outputBuffer = FormatOutput(resources, opts)
case "json", "yaml":
var err error
if jsonResponse, err = json.MarshalIndent(resources, "", " "); err != nil {
return "", err
}
}

unusedRoleBindings, err := unusedResourceFormatter(outputFormat, outputBuffer, opts, jsonResponse)
if err != nil {
fmt.Printf("err: %v\n", err)
}

return unusedRoleBindings, nil
}
Loading

0 comments on commit 673c21e

Please sign in to comment.