Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Discover unused RoleBindings #362

Merged
merged 16 commits into from
Oct 13, 2024
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"})
}
nati-elmaliach marked this conversation as resolved.
Show resolved Hide resolved
}

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
Loading