├── .gitignore ├── cmd ├── root.go ├── version.go ├── list.go ├── cluster.go ├── flag.go └── get.go ├── internal ├── errs │ └── errors.go ├── aws │ ├── oidc.go │ ├── cert_test.go │ ├── role.go │ ├── cluster.go │ ├── cert.go │ ├── event.go │ └── client.go ├── k8s │ ├── kubeconfig_test.go │ ├── kubeconfig.go │ └── client.go └── out │ └── table.go ├── main.go ├── .github ├── workflows │ └── pipeline.yml └── dependabot.yml ├── .goreleaser.yaml ├── LICENSE ├── go.mod ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /kubectl-iam4sa 2 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | var ( 8 | RootCmd = &cobra.Command{} 9 | 10 | Version string 11 | GlobalFlags Flags 12 | ) 13 | 14 | func init() { 15 | InitPersistentFlags(RootCmd, &GlobalFlags) 16 | } 17 | -------------------------------------------------------------------------------- /internal/errs/errors.go: -------------------------------------------------------------------------------- 1 | package errs 2 | 3 | type ErrNotFound struct { 4 | msg string 5 | } 6 | 7 | func NewErrNotFound(msg string) *ErrNotFound { 8 | return &ErrNotFound{msg: msg} 9 | } 10 | 11 | func (e *ErrNotFound) Error() string { 12 | return e.msg 13 | } 14 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/pete911/kubectl-iam4sa/cmd" 5 | "os" 6 | ) 7 | 8 | var Version = "dev" 9 | 10 | func main() { 11 | cmd.Version = Version 12 | if err := cmd.RootCmd.Execute(); err != nil { 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: pipeline 2 | 3 | on: [push] 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | go: 10 | uses: pete911/github-actions/.github/workflows/go.yml@main 11 | go-release: 12 | needs: 13 | - go 14 | uses: pete911/github-actions/.github/workflows/go-releaser.yml@main 15 | secrets: 16 | PUBLIC_REPO_TOKEN: ${{ secrets.PUBLIC_REPO_TOKEN }} 17 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var ( 9 | cmdVersion = &cobra.Command{ 10 | Use: "version", 11 | Short: "print version", 12 | Long: "", 13 | Run: runVersionCmd, 14 | } 15 | ) 16 | 17 | func init() { 18 | RootCmd.AddCommand(cmdVersion) 19 | } 20 | 21 | func runVersionCmd(_ *cobra.Command, _ []string) { 22 | fmt.Println(Version) 23 | } 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | groups: 8 | go: 9 | patterns: 10 | - '*' 11 | 12 | - package-ecosystem: github-actions 13 | directory: /.github 14 | schedule: 15 | interval: daily 16 | groups: 17 | github-actions: 18 | patterns: 19 | - '*' 20 | 21 | -------------------------------------------------------------------------------- /internal/aws/oidc.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go-v2/aws" 5 | "github.com/aws/aws-sdk-go-v2/service/iam" 6 | "time" 7 | ) 8 | 9 | type OidcProvider struct { 10 | Arn string 11 | ClientIDs []string 12 | CreateDate time.Time 13 | Thumbprints []string 14 | Url string 15 | } 16 | 17 | func toOidcProvider(oidc *iam.GetOpenIDConnectProviderOutput, arn string) OidcProvider { 18 | return OidcProvider{ 19 | Arn: arn, 20 | ClientIDs: oidc.ClientIDList, 21 | CreateDate: aws.ToTime(oidc.CreateDate), 22 | Thumbprints: oidc.ThumbprintList, 23 | Url: aws.ToString(oidc.Url), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /internal/k8s/kubeconfig_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func Test_getFlagValue(t *testing.T) { 10 | tcs := []struct { 11 | args []string 12 | flag string 13 | expected string 14 | }{ 15 | {[]string{"--profile", "default", "--region", "eu-west-2"}, "--profile", "default"}, 16 | {[]string{"--profile", "default", "--region", "eu-west-2"}, "--region", "eu-west-2"}, 17 | {[]string{"--profile", "default", "--region"}, "--region", ""}, 18 | } 19 | 20 | for _, tc := range tcs { 21 | actual := getFlagValue(tc.args, tc.flag) 22 | assert.Equal(t, tc.expected, actual, fmt.Sprintf("args: %v flag %s", tc.args, tc.flag)) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /internal/out/table.go: -------------------------------------------------------------------------------- 1 | package out 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "strings" 8 | "text/tabwriter" 9 | ) 10 | 11 | type Table struct { 12 | logger *slog.Logger 13 | writer *tabwriter.Writer 14 | } 15 | 16 | func NewTable(logger *slog.Logger) Table { 17 | return Table{ 18 | logger: logger, 19 | writer: tabwriter.NewWriter(os.Stdout, 1, 1, 2, ' ', 0), 20 | } 21 | } 22 | 23 | func (t Table) AddRow(columns ...string) { 24 | if _, err := fmt.Fprintln(t.writer, strings.Join(columns, "\t")); err != nil { 25 | t.logger.Error(fmt.Sprintf("table: add row: %v", err)) 26 | } 27 | } 28 | 29 | func (t Table) Print() { 30 | if err := t.writer.Flush(); err != nil { 31 | t.logger.Error(fmt.Sprintf("table: print: %v", err)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/aws/cert_test.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func Test_getAddr(t *testing.T) { 11 | tcs := []struct { 12 | host string 13 | expected string 14 | }{ 15 | {"https://test.com", "test.com:443"}, 16 | {"http://test.com", "test.com:80"}, 17 | {"test.com", "test.com:443"}, 18 | {"https://test.com/some/path", "test.com:443"}, 19 | {"http://test.com/some/path", "test.com:80"}, 20 | {"test.com/some/path", "test.com:443"}, 21 | } 22 | 23 | for _, tc := range tcs { 24 | actual, err := getHostAndPort(tc.host) 25 | require.NoError(t, err) 26 | assert.Equal(t, tc.expected, actual, fmt.Sprintf("host: %s", tc.host)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goos: 5 | - linux 6 | - windows 7 | - darwin 8 | goarch: 9 | - amd64 10 | - arm64 11 | ldflags: 12 | - -X main.Version={{.Version}} 13 | 14 | archives: 15 | - format: tar.gz 16 | format_overrides: 17 | - goos: windows 18 | format: zip 19 | checksum: 20 | name_template: 'checksums.txt' 21 | snapshot: 22 | name_template: "{{ incpatch .Version }}-next" 23 | changelog: 24 | sort: asc 25 | filters: 26 | exclude: 27 | - '^docs:' 28 | - '^test:' 29 | release: 30 | github: 31 | owner: pete911 32 | name: kubectl-iam4sa 33 | brews: 34 | - repository: 35 | owner: pete911 36 | name: homebrew-tap 37 | token: "{{ .Env.GITHUB_TOKEN }}" 38 | name: kubectl-iam4sa 39 | homepage: "https://github.com/pete911/kubectl-iam4sa" 40 | description: "debug IAM roles for service accounts" 41 | folder: Formula 42 | install: | 43 | bin.install "kubectl-iam4sa" 44 | test: | 45 | assert_match /Usage/, shell_output("#{bin}/kubectl-iam4sa -h", 0) 46 | -------------------------------------------------------------------------------- /internal/aws/role.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "fmt" 5 | "github.com/aws/aws-sdk-go-v2/aws" 6 | "github.com/aws/aws-sdk-go-v2/service/iam/types" 7 | "net/url" 8 | "time" 9 | ) 10 | 11 | type Role struct { 12 | ARN string 13 | Name string 14 | Description string 15 | AssumeRolePolicyDocument string 16 | CreateDate time.Time 17 | RoleLastUsed time.Time 18 | } 19 | 20 | func (c Client) toRole(role *types.Role) Role { 21 | roleName := aws.ToString(role.RoleName) 22 | document, err := url.QueryUnescape(aws.ToString(role.AssumeRolePolicyDocument)) 23 | if err != nil { 24 | c.logger.Warn(fmt.Sprintf("unescape %s assume role policy: %v", roleName, err)) 25 | } 26 | 27 | return Role{ 28 | ARN: aws.ToString(role.Arn), 29 | Name: roleName, 30 | Description: aws.ToString(role.Description), 31 | AssumeRolePolicyDocument: document, 32 | CreateDate: aws.ToTime(role.CreateDate), 33 | RoleLastUsed: aws.ToTime(role.RoleLastUsed.LastUsedDate), 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Peter Reisinger 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/aws/cluster.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go-v2/aws" 5 | "github.com/aws/aws-sdk-go-v2/service/eks/types" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type Cluster struct { 11 | Arn string 12 | Name string 13 | Certificate string 14 | CreatedAt time.Time 15 | Endpoint string 16 | OidcIssuer string 17 | RoleArn string 18 | Status string 19 | } 20 | 21 | func (c Cluster) OidcIssuerFingerprint() (string, error) { 22 | return FingerprintSHA1(c.OidcIssuer, false) 23 | } 24 | 25 | func (c Cluster) OidcIssuerId() string { 26 | parts := strings.Split(c.OidcIssuer, "/") 27 | return parts[len(parts)-1] 28 | } 29 | 30 | func (c Client) toCluster(cluster *types.Cluster) Cluster { 31 | return Cluster{ 32 | Arn: aws.ToString(cluster.Arn), 33 | Name: aws.ToString(cluster.Name), 34 | Certificate: aws.ToString(cluster.CertificateAuthority.Data), 35 | CreatedAt: aws.ToTime(cluster.CreatedAt), 36 | Endpoint: aws.ToString(cluster.Endpoint), 37 | OidcIssuer: aws.ToString(cluster.Identity.Oidc.Issuer), 38 | RoleArn: aws.ToString(cluster.RoleArn), 39 | Status: string(cluster.Status), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/aws/cert.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "crypto/sha1" 5 | "crypto/tls" 6 | "fmt" 7 | "net" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | // FingerprintSHA1 returns certificate sha1 fingerprint e.g. oidc.eks.eu-west-2.amazonaws.com 13 | func FingerprintSHA1(addr string, tlsSkipVerify bool) (string, error) { 14 | hostAndPort, err := getHostAndPort(addr) 15 | if err != nil { 16 | return "", err 17 | } 18 | 19 | conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", hostAndPort, &tls.Config{InsecureSkipVerify: tlsSkipVerify}) 20 | if err != nil { 21 | return "", fmt.Errorf("tcp connection failed: %w", err) 22 | } 23 | 24 | x509Certificates := conn.ConnectionState().PeerCertificates 25 | if len(x509Certificates) == 0 { 26 | return "", fmt.Errorf("no certificates returned from %s", addr) 27 | } 28 | // get last certificate in the chain 29 | fingerprint := sha1.Sum(x509Certificates[len(x509Certificates)-1].Raw) 30 | return fmt.Sprintf("%x", fingerprint), nil 31 | } 32 | 33 | func getHostAndPort(addr string) (string, error) { 34 | u, err := url.Parse(addr) 35 | if err != nil { 36 | return "", err 37 | } 38 | if u.Scheme == "" { 39 | return getHostAndPort(fmt.Sprintf("https://%s", addr)) 40 | } 41 | 42 | port := u.Port() 43 | if port != "0" && port != "" { 44 | return u.Host, nil 45 | } 46 | if u.Scheme == "http" { 47 | return fmt.Sprintf("%s:80", u.Host), nil 48 | } 49 | // default to 443 50 | return fmt.Sprintf("%s:443", u.Host), nil 51 | } 52 | -------------------------------------------------------------------------------- /cmd/list.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pete911/kubectl-iam4sa/internal/aws" 6 | "github.com/pete911/kubectl-iam4sa/internal/k8s" 7 | "github.com/pete911/kubectl-iam4sa/internal/out" 8 | "github.com/spf13/cobra" 9 | "log/slog" 10 | "os" 11 | ) 12 | 13 | var ( 14 | cmdList = &cobra.Command{ 15 | Use: "list", 16 | Short: "list IAM service accounts", 17 | Long: "", 18 | Run: runListCmd, 19 | } 20 | ) 21 | 22 | func init() { 23 | RootCmd.AddCommand(cmdList) 24 | } 25 | 26 | func runListCmd(_ *cobra.Command, args []string) { 27 | logger := GlobalFlags.Logger() 28 | kubeconfig := GlobalFlags.Kubeconfig() 29 | 30 | k8sClient, err := k8s.NewClient(logger, kubeconfig) 31 | if err != nil { 32 | fmt.Printf("k8s client: %v\n", err) 33 | os.Exit(1) 34 | } 35 | 36 | logger.Debug(fmt.Sprintf("kubeconfig: %s", kubeconfig)) 37 | awsClient, err := aws.NewClient(logger, kubeconfig.Region, kubeconfig.ClusterName) 38 | if err != nil { 39 | fmt.Printf("aws client: %v\n", err) 40 | os.Exit(1) 41 | } 42 | 43 | fieldSelector := GlobalFlags.FieldSelector(args) 44 | sas, err := k8sClient.ListIAMServiceAccounts(GlobalFlags.Namespace(), GlobalFlags.Label(), fieldSelector) 45 | if err != nil { 46 | fmt.Printf("list IAM service accounts: %v\n", err) 47 | os.Exit(1) 48 | } 49 | printListTable(logger, awsClient, sas) 50 | } 51 | 52 | func printListTable(logger *slog.Logger, awsClient aws.Client, sas []k8s.ServiceAccount) { 53 | table := out.NewTable(logger) 54 | table.AddRow("NAMESPACE", "SERVICE ACCOUNT", "PODS", "IAM ROLE ACCOUNT", "IAM ROLE", "EVENTS", "FAILED") 55 | for _, sa := range sas { 56 | events, err := awsClient.LookupEvents(sa.Namespace, sa.Name) 57 | if err != nil { 58 | logger.Error(fmt.Sprintf("lookup %s/%s event: %v", sa.Namespace, sa.Name, err)) 59 | } 60 | 61 | numPods := fmt.Sprintf("%d", len(sa.Pods)) 62 | numEvents := fmt.Sprintf("%d", len(events)) 63 | numFailedEvents := fmt.Sprintf("%d", len(events.FailedEvents())) 64 | table.AddRow(sa.Namespace, sa.Name, numPods, sa.RoleAccount(), sa.RoleName(), numEvents, numFailedEvents) 65 | } 66 | table.Print() 67 | } 68 | -------------------------------------------------------------------------------- /internal/aws/event.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/aws" 7 | "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" 8 | "time" 9 | ) 10 | 11 | type Events []Event 12 | 13 | func (e Events) FailedEvents() Events { 14 | var out Events 15 | for _, event := range e { 16 | if event.ErrorMessage != "" || event.ErrorCode != "" { 17 | out = append(out, event) 18 | } 19 | } 20 | return out 21 | } 22 | 23 | type Event struct { 24 | EventTime time.Time `json:"-"` 25 | EventId string `json:"-"` 26 | EventSource string `json:"-"` 27 | EventName string `json:"-"` 28 | UserName string `json:"-"` 29 | ErrorCode string `json:"errorCode"` // set when there's error 30 | ErrorMessage string `json:"errorMessage"` // set when there's error 31 | UserIdentity UserIdentity `json:"userIdentity"` 32 | Region string `json:"awsRegion"` 33 | SourceIP string `json:"sourceIPAddress"` 34 | UserAgent string `json:"userAgent"` 35 | RequestParameters RequestParameters `json:"requestParameters"` 36 | RequestId string `json:"requestId"` 37 | EventType string `json:"eventType"` 38 | } 39 | 40 | type UserIdentity struct { 41 | Type string `json:"type"` 42 | PrincipalId string `json:"principalId"` 43 | UserName string `json:"userName"` 44 | IdentityProvider string `json:"identityProvider"` 45 | } 46 | 47 | type RequestParameters struct { 48 | RoleArn string `json:"roleArn"` 49 | RoleSessionName string `json:"roleSessionName"` 50 | } 51 | 52 | func (c Client) toEvents(events []types.Event) Events { 53 | var out []Event 54 | for _, e := range events { 55 | // set these fields from response, if json unmarshal fails, at least we have some info 56 | event := Event{ 57 | EventTime: aws.ToTime(e.EventTime), 58 | EventId: aws.ToString(e.EventId), 59 | EventSource: aws.ToString(e.EventSource), 60 | EventName: aws.ToString(e.EventName), 61 | UserName: aws.ToString(e.Username), 62 | } 63 | if err := json.Unmarshal([]byte(aws.ToString(e.CloudTrailEvent)), &event); err != nil { 64 | c.logger.Warn(fmt.Sprintf("unmrshal %s event: %v", event.EventId, err)) 65 | } 66 | out = append(out, event) 67 | } 68 | return out 69 | } 70 | -------------------------------------------------------------------------------- /internal/k8s/kubeconfig.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "k8s.io/client-go/rest" 6 | "k8s.io/client-go/tools/clientcmd" 7 | "k8s.io/client-go/tools/clientcmd/api" 8 | ) 9 | 10 | type Kubeconfig struct { 11 | RestConfig *rest.Config 12 | ClusterName string 13 | Region string 14 | Profile string 15 | } 16 | 17 | func (k Kubeconfig) String() string { 18 | return fmt.Sprintf("cluster name: %s region %s", k.ClusterName, k.Region) 19 | } 20 | 21 | func NewKubeconfig(kubeconfigPath string) (Kubeconfig, error) { 22 | clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( 23 | &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, 24 | nil) 25 | 26 | apiConfig, err := clientConfig.RawConfig() 27 | if err != nil { 28 | return Kubeconfig{}, fmt.Errorf("raw config: %v", err) 29 | } 30 | 31 | restConfig, err := clientConfig.ClientConfig() 32 | if err != nil { 33 | return Kubeconfig{}, fmt.Errorf("client configs: %v", err) 34 | } 35 | 36 | user := apiConfig.Contexts[apiConfig.CurrentContext].AuthInfo 37 | exec := apiConfig.AuthInfos[user].Exec 38 | if exec.Command != "aws" { 39 | if exec.Command == "" { 40 | return Kubeconfig{}, fmt.Errorf("exec command is not set in current %s contex, cannot determine cluster name and region", apiConfig.CurrentContext) 41 | } 42 | return Kubeconfig{}, fmt.Errorf("unexpected exec command %s for current %s contex, expected 'aws'", exec.Command, apiConfig.CurrentContext) 43 | } 44 | 45 | env := execEnvToMap(exec.Env) 46 | return Kubeconfig{ 47 | RestConfig: restConfig, 48 | ClusterName: getClusterName(exec.Args), 49 | Region: getRegion(exec.Args, env), 50 | Profile: getProfile(exec.Args, env), 51 | }, nil 52 | } 53 | 54 | func getRegion(args []string, env map[string]string) string { 55 | if v := getFlagValue(args, "--region"); v != "" { 56 | return v 57 | } 58 | return env["AWS_REGION"] 59 | } 60 | 61 | func getProfile(args []string, env map[string]string) string { 62 | if v := getFlagValue(args, "--profile"); v != "" { 63 | return v 64 | } 65 | return env["AWS_PROFILE"] 66 | } 67 | 68 | func getClusterName(args []string) string { 69 | return getFlagValue(args, "--cluster-name") 70 | } 71 | 72 | func getFlagValue(args []string, flag string) string { 73 | for i := range args { 74 | if args[i] == flag && len(args) > i+1 { 75 | return args[i+1] 76 | } 77 | } 78 | return "" 79 | } 80 | 81 | func execEnvToMap(env []api.ExecEnvVar) map[string]string { 82 | out := make(map[string]string) 83 | for _, v := range env { 84 | out[v.Name] = v.Value 85 | } 86 | return out 87 | } 88 | -------------------------------------------------------------------------------- /cmd/cluster.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/pete911/kubectl-iam4sa/internal/aws" 7 | "github.com/pete911/kubectl-iam4sa/internal/errs" 8 | "github.com/spf13/cobra" 9 | "log/slog" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var ( 15 | cmdCluster = &cobra.Command{ 16 | Use: "cluster", 17 | Short: "EKS cluster oidc information", 18 | Long: "", 19 | Run: runClusterCmd, 20 | } 21 | ) 22 | 23 | func init() { 24 | RootCmd.AddCommand(cmdCluster) 25 | } 26 | 27 | func runClusterCmd(_ *cobra.Command, args []string) { 28 | logger := GlobalFlags.Logger() 29 | kubeconfig := GlobalFlags.Kubeconfig() 30 | 31 | logger.Debug(fmt.Sprintf("kubeconfig: %s", kubeconfig)) 32 | awsClient, err := aws.NewClient(logger, kubeconfig.Region, kubeconfig.ClusterName) 33 | if err != nil { 34 | fmt.Printf("aws client: %v\n", err) 35 | os.Exit(1) 36 | } 37 | 38 | cluster, err := awsClient.DescribeCluster() 39 | if err != nil { 40 | fmt.Printf("describe cluster: %v\n", err) 41 | os.Exit(1) 42 | } 43 | 44 | oidcProvider, err := awsClient.GetClusterOidcProvider(cluster.OidcIssuerId()) 45 | if err != nil { 46 | // continue if the error is not found, we want to display to the user that there's no oidc provider 47 | var errNotFound *errs.ErrNotFound 48 | if !errors.As(err, &errNotFound) { 49 | fmt.Printf("get cluster oidc provider: %v\n", err) 50 | os.Exit(1) 51 | } 52 | } 53 | printCluster(logger, cluster, oidcProvider) 54 | } 55 | 56 | func printCluster(logger *slog.Logger, cluster aws.Cluster, oidcProvider aws.OidcProvider) { 57 | fingerprint, err := cluster.OidcIssuerFingerprint() 58 | if err != nil { 59 | logger.Error(fmt.Sprintf("oidc cluster issuer fingerprint: %v", err)) 60 | } 61 | 62 | fmt.Printf("Name: %s\n", cluster.Name) 63 | fmt.Printf("Status: %s\n", cluster.Status) 64 | fmt.Printf("Endpoint: %s\n", cluster.Endpoint) 65 | fmt.Printf("Created: %s\n", cluster.CreatedAt.Format(time.RFC3339)) 66 | fmt.Println("OIDC Issuer:") 67 | fmt.Printf(" Url: %s\n", cluster.OidcIssuer) 68 | fmt.Printf(" Thumbprint: %s\n", fingerprint) 69 | if oidcProvider.Url == "" { 70 | fmt.Println("OIDC Provider: not found") 71 | return 72 | } 73 | fmt.Println("OIDC Provider:") 74 | fmt.Printf(" Arn: %s\n", oidcProvider.Arn) 75 | fmt.Printf(" Url: %s\n", oidcProvider.Url) 76 | fmt.Printf(" Created: %s\n", oidcProvider.CreateDate.Format(time.RFC3339)) 77 | fmt.Println(" Client Ids:") 78 | for _, id := range oidcProvider.ClientIDs { 79 | fmt.Printf(" %s\n", id) 80 | } 81 | fmt.Println(" Thumbprints:") 82 | for _, thumbprint := range oidcProvider.Thumbprints { 83 | fmt.Printf(" %s\n", thumbprint) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /cmd/flag.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pete911/kubectl-iam4sa/internal/k8s" 6 | "github.com/spf13/cobra" 7 | "k8s.io/client-go/util/homedir" 8 | "log/slog" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | var logLevels = map[string]slog.Level{"debug": slog.LevelDebug, "info": slog.LevelInfo, "warn": slog.LevelWarn, "error": slog.LevelError} 15 | 16 | type Flags struct { 17 | kubeconfigPath string 18 | logLevel string 19 | namespace string 20 | allNamespaces bool 21 | label string 22 | fieldSelector string 23 | } 24 | 25 | func (f Flags) Kubeconfig() k8s.Kubeconfig { 26 | kubeconfig, err := k8s.NewKubeconfig(f.kubeconfigPath) 27 | if err != nil { 28 | fmt.Printf("load kubeconfig %s: %v", f.kubeconfigPath, err) 29 | os.Exit(1) 30 | } 31 | return kubeconfig 32 | } 33 | 34 | func (f Flags) Logger() *slog.Logger { 35 | if level, ok := logLevels[strings.ToLower(f.logLevel)]; ok { 36 | opts := &slog.HandlerOptions{Level: level} 37 | return slog.New(slog.NewJSONHandler(os.Stderr, opts)) 38 | } 39 | 40 | fmt.Printf("invalid log level %s", f.logLevel) 41 | os.Exit(1) 42 | return nil 43 | } 44 | 45 | func (f Flags) Namespace() string { 46 | if f.allNamespaces { 47 | return "" 48 | } 49 | return f.namespace 50 | } 51 | 52 | func (f Flags) Label() string { 53 | return f.label 54 | } 55 | 56 | func (f Flags) FieldSelector(args []string) string { 57 | for _, v := range args { 58 | fieldSelectors := strings.Split(f.fieldSelector, ",") 59 | fieldSelectors = append(fieldSelectors, fmt.Sprintf("metadata.name=%s", v)) 60 | f.fieldSelector = strings.Join(fieldSelectors, ",") 61 | } 62 | return f.fieldSelector 63 | } 64 | 65 | func InitPersistentFlags(cmd *cobra.Command, flags *Flags) { 66 | defaultKubeconfig := filepath.Join(homedir.HomeDir(), ".kube", "config") 67 | cmd.PersistentFlags().StringVar( 68 | &flags.kubeconfigPath, 69 | "kubeconfig", 70 | getStringEnv("KUBECONFIG", defaultKubeconfig), 71 | "path to kubeconfig file", 72 | ) 73 | cmd.PersistentFlags().StringVar( 74 | &flags.logLevel, 75 | "log-level", 76 | "warn", 77 | "log level - debug, info, warn, error", 78 | ) 79 | cmd.PersistentFlags().StringVarP( 80 | &flags.namespace, 81 | "namespace", 82 | "n", 83 | "default", 84 | "kubernetes namespace", 85 | ) 86 | cmd.PersistentFlags().BoolVarP( 87 | &flags.allNamespaces, 88 | "all-namespaces", 89 | "A", 90 | false, 91 | "all kubernetes namespaces", 92 | ) 93 | cmd.PersistentFlags().StringVarP( 94 | &flags.label, 95 | "label", 96 | "l", 97 | "", 98 | "kubernetes label", 99 | ) 100 | cmd.PersistentFlags().StringVarP( 101 | &flags.fieldSelector, 102 | "field-selector", 103 | "", 104 | "", 105 | "kubernetes field selector", 106 | ) 107 | } 108 | 109 | func getStringEnv(envName string, defaultValue string) string { 110 | if env, ok := os.LookupEnv(envName); ok { 111 | return env 112 | } 113 | return defaultValue 114 | } 115 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/pete911/kubectl-iam4sa 2 | 3 | go 1.25.0 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.41.0 7 | github.com/aws/aws-sdk-go-v2/config v1.32.6 8 | github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.4 9 | github.com/aws/aws-sdk-go-v2/service/eks v1.76.3 10 | github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 11 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 12 | github.com/spf13/cobra v1.10.2 13 | github.com/stretchr/testify v1.11.1 14 | k8s.io/apimachinery v0.35.0 15 | k8s.io/client-go v0.35.0 16 | ) 17 | 18 | require ( 19 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect 21 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect 22 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect 23 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 24 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect 26 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect 27 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect 28 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect 29 | github.com/aws/smithy-go v1.24.0 // indirect 30 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 31 | github.com/emicklei/go-restful/v3 v3.12.2 // indirect 32 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 33 | github.com/go-logr/logr v1.4.3 // indirect 34 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 35 | github.com/go-openapi/jsonreference v0.21.0 // indirect 36 | github.com/go-openapi/swag v0.23.0 // indirect 37 | github.com/google/gnostic-models v0.7.0 // indirect 38 | github.com/google/uuid v1.6.0 // indirect 39 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 40 | github.com/josharian/intern v1.0.0 // indirect 41 | github.com/json-iterator/go v1.1.12 // indirect 42 | github.com/mailru/easyjson v0.9.0 // indirect 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 44 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 45 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 46 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 47 | github.com/spf13/pflag v1.0.9 // indirect 48 | github.com/x448/float16 v0.8.4 // indirect 49 | go.yaml.in/yaml/v2 v2.4.3 // indirect 50 | go.yaml.in/yaml/v3 v3.0.4 // indirect 51 | golang.org/x/net v0.47.0 // indirect 52 | golang.org/x/oauth2 v0.30.0 // indirect 53 | golang.org/x/sys v0.38.0 // indirect 54 | golang.org/x/term v0.37.0 // indirect 55 | golang.org/x/text v0.31.0 // indirect 56 | golang.org/x/time v0.9.0 // indirect 57 | google.golang.org/protobuf v1.36.8 // indirect 58 | gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect 59 | gopkg.in/inf.v0 v0.9.1 // indirect 60 | gopkg.in/yaml.v3 v3.0.1 // indirect 61 | k8s.io/api v0.35.0 // indirect 62 | k8s.io/klog/v2 v2.130.1 // indirect 63 | k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect 64 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 65 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 66 | sigs.k8s.io/randfill v1.0.0 // indirect 67 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect 68 | sigs.k8s.io/yaml v1.6.0 // indirect 69 | ) 70 | -------------------------------------------------------------------------------- /cmd/get.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/pete911/kubectl-iam4sa/internal/aws" 7 | "github.com/pete911/kubectl-iam4sa/internal/k8s" 8 | "github.com/pete911/kubectl-iam4sa/internal/out" 9 | "github.com/spf13/cobra" 10 | "log/slog" 11 | "os" 12 | "time" 13 | ) 14 | 15 | var ( 16 | cmdGet = &cobra.Command{ 17 | Use: "get", 18 | Short: "get IAM service account", 19 | Long: "", 20 | Run: runGetCmd, 21 | } 22 | ) 23 | 24 | func init() { 25 | RootCmd.AddCommand(cmdGet) 26 | } 27 | 28 | func runGetCmd(_ *cobra.Command, args []string) { 29 | logger := GlobalFlags.Logger() 30 | kubeconfig := GlobalFlags.Kubeconfig() 31 | 32 | k8sClient, err := k8s.NewClient(logger, kubeconfig) 33 | if err != nil { 34 | fmt.Printf("k8s client: %v\n", err) 35 | os.Exit(1) 36 | } 37 | 38 | logger.Debug(fmt.Sprintf("kubeconfig: %s", kubeconfig)) 39 | awsClient, err := aws.NewClient(logger, kubeconfig.Region, kubeconfig.ClusterName) 40 | if err != nil { 41 | fmt.Printf("aws client: %v\n", err) 42 | os.Exit(1) 43 | } 44 | 45 | fieldSelector := GlobalFlags.FieldSelector(args) 46 | sas, err := k8sClient.ListIAMServiceAccounts(GlobalFlags.Namespace(), GlobalFlags.Label(), fieldSelector) 47 | if err != nil { 48 | fmt.Printf("get IAM service accounts: %v\n", err) 49 | os.Exit(1) 50 | } 51 | printGet(logger, awsClient, sas) 52 | } 53 | 54 | func printGet(logger *slog.Logger, awsClient aws.Client, sas []k8s.ServiceAccount) { 55 | for _, sa := range sas { 56 | printGetSa(logger, awsClient, sa) 57 | } 58 | } 59 | 60 | func printGetSa(logger *slog.Logger, awsClient aws.Client, sa k8s.ServiceAccount) { 61 | role, err := awsClient.GetIAMRole(sa.RoleName()) 62 | if err != nil { 63 | logger.Error(fmt.Sprintf("get role for %s/%s service account: %v", sa.Namespace, sa.Name, err)) 64 | } 65 | 66 | events, err := awsClient.LookupEvents(sa.Namespace, sa.Name) 67 | if err != nil { 68 | logger.Error(fmt.Sprintf("lookup %s/%s event: %v", sa.Namespace, sa.Name, err)) 69 | } 70 | failedEvents := events.FailedEvents() 71 | 72 | printSA(sa) 73 | 74 | fmt.Println() 75 | printRole(logger, sa, role) 76 | 77 | // if there are any failed events, lets print them 78 | if len(failedEvents) != 0 { 79 | fmt.Println() 80 | fmt.Println("Failed Events:") 81 | printEvents(logger, sa, failedEvents) 82 | } 83 | } 84 | 85 | func printSA(sa k8s.ServiceAccount) { 86 | fmt.Printf("Name: %s\n", sa.Name) 87 | fmt.Printf("Namespace: %s\n", sa.Namespace) 88 | fmt.Println("Pods:") 89 | for _, pod := range sa.Pods { 90 | fmt.Printf(" %s\n", pod) 91 | } 92 | } 93 | 94 | func printRole(logger *slog.Logger, sa k8s.ServiceAccount, role aws.Role) { 95 | fmt.Printf("Service Account Role: %s\n", sa.IamRoleArn) 96 | if role.ARN == "" { 97 | fmt.Println("AWS Role Policy Document: not found") 98 | return 99 | } 100 | jsonPrettyPrint(logger, role.AssumeRolePolicyDocument) 101 | } 102 | 103 | func printEvents(logger *slog.Logger, sa k8s.ServiceAccount, events []aws.Event) { 104 | table := out.NewTable(logger) 105 | table.AddRow("TIME", "CODE", "MESSAGE", "REQUEST ROLE", "SA ROLE") 106 | for i, event := range events { 107 | // print max last 5 failed events 108 | if i == 5 { 109 | break 110 | } 111 | table.AddRow(event.EventTime.Format(time.RFC3339), event.ErrorCode, event.ErrorMessage, event.RequestParameters.RoleArn, sa.IamRoleArn) 112 | } 113 | table.Print() 114 | } 115 | 116 | func jsonPrettyPrint(logger *slog.Logger, in string) { 117 | var inJson any 118 | if err := json.Unmarshal([]byte(in), &inJson); err != nil { 119 | logger.Error(err.Error()) 120 | return 121 | } 122 | b, err := json.MarshalIndent(inJson, "", " ") 123 | if err != nil { 124 | logger.Error(err.Error()) 125 | return 126 | } 127 | fmt.Println(string(b)) 128 | } 129 | -------------------------------------------------------------------------------- /internal/k8s/client.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/client-go/kubernetes" 8 | corev1 "k8s.io/client-go/kubernetes/typed/core/v1" 9 | "log/slog" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const iamRoleARNAnnotation = "eks.amazonaws.com/role-arn" 15 | 16 | type ServiceAccount struct { 17 | Name string 18 | Namespace string 19 | IamRoleArn string 20 | Pods []string 21 | } 22 | 23 | func (s ServiceAccount) RoleAccount() string { 24 | parts := strings.Split(s.IamRoleArn, ":") 25 | return parts[len(parts)-2] 26 | } 27 | 28 | func (s ServiceAccount) RoleName() string { 29 | parts := strings.Split(s.IamRoleArn, "/") 30 | return parts[len(parts)-1] 31 | } 32 | 33 | type Client struct { 34 | logger *slog.Logger 35 | config Kubeconfig 36 | coreV1 corev1.CoreV1Interface 37 | } 38 | 39 | func NewClient(logger *slog.Logger, config Kubeconfig) (Client, error) { 40 | cs, err := kubernetes.NewForConfig(config.RestConfig) 41 | if err != nil { 42 | return Client{}, err 43 | } 44 | return Client{ 45 | logger: logger, 46 | config: config, 47 | coreV1: cs.CoreV1(), 48 | }, nil 49 | } 50 | 51 | func (c Client) ListIAMServiceAccounts(namespace, labelSelector, fieldSelector string) ([]ServiceAccount, error) { 52 | if namespace == "" { 53 | return c.listAllIAMServiceAccounts(labelSelector, fieldSelector) 54 | } 55 | return c.listIAMServiceAccounts(namespace, labelSelector, fieldSelector) 56 | } 57 | 58 | func (c Client) listAllIAMServiceAccounts(labelSelector, fieldSelector string) ([]ServiceAccount, error) { 59 | namespaces, err := c.getNamespaces() 60 | if err != nil { 61 | return nil, fmt.Errorf("get namespaces: %w", err) 62 | } 63 | 64 | var allServiceAccounts []ServiceAccount 65 | for _, namespace := range namespaces { 66 | serviceAccounts, err := c.listIAMServiceAccounts(namespace, labelSelector, fieldSelector) 67 | if err != nil { 68 | return nil, err 69 | } 70 | allServiceAccounts = append(allServiceAccounts, serviceAccounts...) 71 | } 72 | return allServiceAccounts, nil 73 | } 74 | 75 | func (c Client) listIAMServiceAccounts(namespace, labelSelector, fieldSelector string) ([]ServiceAccount, error) { 76 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 77 | defer cancel() 78 | 79 | serviceAccountList, err := c.coreV1.ServiceAccounts(namespace).List(ctx, metav1.ListOptions{LabelSelector: labelSelector, FieldSelector: fieldSelector}) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | var serviceAccounts []ServiceAccount 85 | for _, serviceAccount := range serviceAccountList.Items { 86 | if roleARN, ok := serviceAccount.Annotations[iamRoleARNAnnotation]; ok { 87 | pods, err := c.listPods(namespace, serviceAccount.Name) 88 | if err != nil { 89 | return nil, fmt.Errorf("list pods for %s/%s service account: %v", namespace, serviceAccount.Name, err) 90 | } 91 | serviceAccounts = append(serviceAccounts, ServiceAccount{ 92 | Name: serviceAccount.Name, 93 | Namespace: serviceAccount.Namespace, 94 | IamRoleArn: roleARN, 95 | Pods: pods, 96 | }) 97 | } 98 | } 99 | return serviceAccounts, nil 100 | } 101 | 102 | func (c Client) listPods(namespace, serviceAccountName string) ([]string, error) { 103 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 104 | defer cancel() 105 | 106 | podList, err := c.coreV1.Pods(namespace).List(ctx, metav1.ListOptions{FieldSelector: fmt.Sprintf("spec.serviceAccountName=%s", serviceAccountName)}) 107 | if err != nil { 108 | return nil, err 109 | } 110 | var out []string 111 | for _, pod := range podList.Items { 112 | out = append(out, pod.Name) 113 | } 114 | return out, nil 115 | } 116 | 117 | func (c Client) getNamespaces() ([]string, error) { 118 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 119 | defer cancel() 120 | 121 | namespaceList, err := c.coreV1.Namespaces().List(ctx, metav1.ListOptions{}) 122 | if err != nil { 123 | return nil, err 124 | } 125 | var out []string 126 | for _, ns := range namespaceList.Items { 127 | out = append(out, ns.Name) 128 | } 129 | return out, nil 130 | } 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kubectl-iam4sa 2 | Debug IAM roles for service accounts. User needs to have access to cluster, AWS IAM and CloudTrail API. 3 | 4 | ```shell 5 | Available Commands: 6 | cluster EKS cluster oidc information 7 | get get IAM service account 8 | help help about any command 9 | list list IAM service accounts 10 | version print version 11 | 12 | Flags: 13 | -A, --all-namespaces all kubernetes namespaces 14 | --field-selector string kubernetes field selector 15 | -h, --help help for this command 16 | --kubeconfig string path to kubeconfig file (default "~/.kube/config") 17 | -l, --label string kubernetes label 18 | --log-level string log level - debug, info, warn, error (default "warn") 19 | -n, --namespace string kubernetes namespace (default "default") 20 | ``` 21 | 22 | ## cluster information 23 | 24 | `kubectl-iam4sa cluster` 25 | 26 | ``` 27 | Name: main 28 | Status: ACTIVE 29 | Endpoint: https://123456789123.gr7.eu-west-2.eks.amazonaws.com 30 | Created: 2023-10-13T08:41:17Z 31 | OIDC Issuer: 32 | Url: https://oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123 33 | Thumbprint: 9e9e9e9e999999999eeeee9992e9999998888877 34 | OIDC Provider: 35 | Arn: arn:aws:iam::123456789123:oidc-provider/oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123 36 | Url: oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123 37 | Created: 2023-10-13T08:48:18Z 38 | Client Ids: 39 | sts.amazonaws.com 40 | Thumbprints: 41 | 9e9e9e9e999999999eeeee9992e9999998888877 42 | ``` 43 | 44 | Verify that the OIDC Provider is found and has matching url and thumbprint. 45 | 46 | ## list service accounts with IAM role 47 | 48 | `kubectl-iam4sa list -A` - list service accounts in all namespaces 49 | ``` 50 | NAMESPACE SERVICE ACCOUNT PODS IAM ROLE ACCOUNT IAM ROLE EVENTS FAILED 51 | default ebs-csi-controller-sa 2 123456789123 ebs-csi-controller 0 0 52 | karpenter karpenter 2 123456789123 karpenter-controller 15 0 53 | prometheus amp-iamproxy-ingest-service-account 1 123456789123 prometheus 40 25 54 | ``` 55 | List displays service accounts with `eks.amazonaws.com/role-arn` annotations, number of pods that use this service 56 | account. IAM Role account and name is from the service account annotation. Events is a number of events 57 | (from CloudTrail) in the past 12 hours for this service account. 58 | 59 | ## get service account 60 | 61 | `kubectl-iam4sa get -n ` 62 | ``` 63 | Name: amp-iamproxy-ingest-service-account 64 | Namespace: prometheus 65 | Pods: 66 | prometheus-server-abc-xyz 67 | 68 | Service Account Role: arn:aws:iam::123456789123:role/prometheus 69 | { 70 | "Statement": [ 71 | { 72 | "Action": "sts:AssumeRoleWithWebIdentity", 73 | "Condition": { 74 | "StringEquals": { 75 | "oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123:aud": "sts.amazonaws.com", 76 | "oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123:sub": "system:serviceaccount:prometheus:amp-iamproxy-ingest-service-account" 77 | } 78 | }, 79 | "Effect": "Allow", 80 | "Principal": { 81 | "Federated": "arn:aws:iam::123456789123:oidc-provider/oidc.eks.eu-west-2.amazonaws.com/id/abcxyz123" 82 | }, 83 | "Sid": "" 84 | } 85 | ], 86 | "Version": "2012-10-17" 87 | } 88 | 89 | Failed Events: 90 | TIME CODE MESSAGE REQUEST ROLE ACTUAL ROLE 91 | 2023-11-23T15:35:48Z AccessDenied An unknown error occurred arn:aws:iam::123456789123:role/promethus-ingest arn:aws:iam::123456789123:role/prometheus 92 | 2023-11-23T15:19:08Z AccessDenied An unknown error occurred arn:aws:iam::123456789123:role/promethus-ingest arn:aws:iam::123456789123:role/prometheus 93 | ``` 94 | 95 | List more detailed information about service account(s) and IAM role(s). Verify principal and condition in the trust 96 | policy with output from `kubectl-iam4sa cluster` output. 97 | 98 | In the example above, we can see in the failed events, that the pod is requesting `prometheus-ingest` role, but the role 99 | that is set in annotation is `prometheus`. In this case most likely the pod needs to be restarted. 100 | 101 | ## download 102 | 103 | - [binary](https://github.com/pete911/kubectl-iam4sa/releases) 104 | 105 | ## build/install 106 | 107 | ### brew 108 | 109 | - add tap `brew tap pete911/tap` 110 | - install `brew install kubectl-iam4sa` 111 | 112 | ### go 113 | 114 | [go](https://golang.org/dl/) has to be installed. 115 | - build `go build` 116 | - install `go install` 117 | -------------------------------------------------------------------------------- /internal/aws/client.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/aws/aws-sdk-go-v2/aws" 8 | "github.com/aws/aws-sdk-go-v2/aws/transport/http" 9 | "github.com/aws/aws-sdk-go-v2/config" 10 | "github.com/aws/aws-sdk-go-v2/service/cloudtrail" 11 | cloudtrailtypes "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" 12 | "github.com/aws/aws-sdk-go-v2/service/eks" 13 | "github.com/aws/aws-sdk-go-v2/service/iam" 14 | "github.com/aws/aws-sdk-go-v2/service/sts" 15 | "github.com/pete911/kubectl-iam4sa/internal/errs" 16 | "log/slog" 17 | "time" 18 | ) 19 | 20 | const eventsHours = 12 21 | 22 | type Client struct { 23 | logger *slog.Logger 24 | clusterName string 25 | account string 26 | region string 27 | iamClient *iam.Client 28 | cloudTrailClient *cloudtrail.Client 29 | eksClient *eks.Client 30 | } 31 | 32 | func NewClient(logger *slog.Logger, region, clusterName string) (Client, error) { 33 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 34 | defer cancel() 35 | 36 | cfg, err := config.LoadDefaultConfig(ctx) 37 | if err != nil { 38 | return Client{}, err 39 | } 40 | 41 | // we should use the same region as is in the kubeconfig, if this is not the case, log warning 42 | if region == "" { 43 | logger.Warn(fmt.Sprintf("no region supplied, defaulting to %s, this can be different from cluster region", cfg.Region)) 44 | } else { 45 | cfg.Region = region 46 | } 47 | 48 | out, err := sts.NewFromConfig(cfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) 49 | if err != nil { 50 | return Client{}, err 51 | } 52 | account := aws.ToString(out.Account) 53 | 54 | return Client{ 55 | logger: logger, 56 | clusterName: clusterName, 57 | account: account, 58 | region: cfg.Region, 59 | iamClient: iam.NewFromConfig(cfg), 60 | cloudTrailClient: cloudtrail.NewFromConfig(cfg), 61 | eksClient: eks.NewFromConfig(cfg), 62 | }, nil 63 | } 64 | 65 | func (c Client) GetIAMRole(roleName string) (Role, error) { 66 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 67 | defer cancel() 68 | 69 | out, err := c.iamClient.GetRole(ctx, &iam.GetRoleInput{RoleName: aws.String(roleName)}) 70 | if err != nil { 71 | err = handleResponseError(err, fmt.Sprintf("role %s", roleName)) 72 | return Role{}, err 73 | } 74 | return c.toRole(out.Role), nil 75 | } 76 | 77 | func (c Client) LookupEvents(namespace, serviceAccount string) (Events, error) { 78 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 79 | defer cancel() 80 | 81 | username := fmt.Sprintf("system:serviceaccount:%s:%s", namespace, serviceAccount) 82 | in := &cloudtrail.LookupEventsInput{ 83 | LookupAttributes: []cloudtrailtypes.LookupAttribute{{ 84 | AttributeKey: cloudtrailtypes.LookupAttributeKeyUsername, 85 | AttributeValue: aws.String(username), 86 | }}, 87 | StartTime: aws.Time(time.Now().Add(-(eventsHours * time.Hour))), 88 | } 89 | 90 | var events []cloudtrailtypes.Event 91 | for { 92 | out, err := c.cloudTrailClient.LookupEvents(ctx, in) 93 | if err != nil { 94 | err = handleResponseError(err, fmt.Sprintf("events for %s user", username)) 95 | return nil, err 96 | } 97 | events = append(events, out.Events...) 98 | if aws.ToString(out.NextToken) == "" { 99 | break 100 | } 101 | in.NextToken = out.NextToken 102 | } 103 | return c.toEvents(events), nil 104 | } 105 | 106 | func (c Client) DescribeCluster() (Cluster, error) { 107 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 108 | defer cancel() 109 | 110 | out, err := c.eksClient.DescribeCluster(ctx, &eks.DescribeClusterInput{Name: aws.String(c.clusterName)}) 111 | if err != nil { 112 | err = handleResponseError(err, fmt.Sprintf("cluster %s", c.clusterName)) 113 | return Cluster{}, err 114 | } 115 | return c.toCluster(out.Cluster), nil 116 | } 117 | 118 | func (c Client) GetClusterOidcProvider(clusterOidcIssuerId string) (OidcProvider, error) { 119 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 120 | defer cancel() 121 | 122 | arn := fmt.Sprintf("arn:aws:iam::%s:oidc-provider/oidc.eks.%s.amazonaws.com/id/%s", c.account, c.region, clusterOidcIssuerId) 123 | out, err := c.iamClient.GetOpenIDConnectProvider(ctx, &iam.GetOpenIDConnectProviderInput{OpenIDConnectProviderArn: aws.String(arn)}) 124 | if err != nil { 125 | err = handleResponseError(err, fmt.Sprintf("oidc provider %s", arn)) 126 | return OidcProvider{}, err 127 | } 128 | return toOidcProvider(out, arn), nil 129 | } 130 | 131 | // handleResponseError converts error to custom error (if possible) to make handling of errors easier 132 | func handleResponseError(err error, requestName string) error { 133 | var responseError *http.ResponseError 134 | if errors.As(err, &responseError) && responseError.HTTPStatusCode() == 404 { 135 | return errs.NewErrNotFound(fmt.Sprintf("%s: not found", requestName)) 136 | } 137 | return fmt.Errorf("%s: %w", requestName, err) 138 | } 139 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 2 | github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 3 | github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= 4 | github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= 5 | github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= 6 | github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= 7 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= 8 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= 9 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= 10 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= 11 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= 12 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= 13 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= 14 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= 15 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= 16 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= 17 | github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.4 h1:paDKcKBWPFh/uaTEMPMXyVj5Qsz2dlHaJCi+6yg1C84= 18 | github.com/aws/aws-sdk-go-v2/service/cloudtrail v1.55.4/go.mod h1:06x0N2mdQ+l0uv/fjo8p96812Ex8sxq24LmC8JPajmg= 19 | github.com/aws/aws-sdk-go-v2/service/eks v1.76.3 h1:840uwcJTIwrMPLuEUQVFKZbPgwnYzc5WDyXMiMYm5Ts= 20 | github.com/aws/aws-sdk-go-v2/service/eks v1.76.3/go.mod h1:7IU8o/Snul26xioEWN5tgoOas1ISPGsiq5gME5rPh3o= 21 | github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 h1:xNCUk9XN6Pa9PyzbEfzgRpvEIVlqtth402yjaWvNMu4= 22 | github.com/aws/aws-sdk-go-v2/service/iam v1.53.1/go.mod h1:GNQZL4JRSGH6L0/SNGOtffaB1vmlToYp3KtcUIB0NhI= 23 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= 24 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= 25 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= 26 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= 27 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= 28 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= 29 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= 30 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= 31 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= 32 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= 33 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= 34 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= 35 | github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 36 | github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 37 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 38 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 40 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 41 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 42 | github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= 43 | github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 44 | github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 45 | github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 46 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 47 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 48 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 49 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 50 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 51 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 52 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 53 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 54 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 55 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 56 | github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= 57 | github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= 58 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 59 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 60 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 61 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= 62 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 63 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 64 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 65 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 66 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 67 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 68 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 69 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 70 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 71 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 72 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 73 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 74 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 75 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 76 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 77 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 78 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 79 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 80 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 81 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= 82 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 83 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 84 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 85 | github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= 86 | github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= 87 | github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= 88 | github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= 89 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 90 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 91 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 92 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 93 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 94 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 95 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 96 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 97 | github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 98 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 99 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 100 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 101 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 102 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 103 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 104 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 105 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 106 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 107 | go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= 108 | go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 109 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= 110 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 111 | golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= 112 | golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= 113 | golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 114 | golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 115 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= 116 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 117 | golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= 118 | golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 119 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 120 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 121 | golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= 122 | golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 123 | golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= 124 | golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 125 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 126 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 127 | golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= 128 | golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 129 | google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= 130 | google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= 131 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 132 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 133 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 134 | gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= 135 | gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 136 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 137 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 138 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 139 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 140 | k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= 141 | k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= 142 | k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= 143 | k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= 144 | k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= 145 | k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= 146 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 147 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 148 | k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= 149 | k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= 150 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= 151 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 152 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= 153 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= 154 | sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= 155 | sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= 156 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= 157 | sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= 158 | sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= 159 | sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= 160 | --------------------------------------------------------------------------------