internal/resources/providers/awslib/iam/user.go (231 lines of code) (raw):

// Licensed to Elasticsearch B.V. under one or more contributor // license agreements. See the NOTICE file distributed with // this work for additional information regarding copyright // ownership. Elasticsearch B.V. licenses this file to you under // the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. package iam import ( "bytes" "context" "errors" "fmt" "strconv" "strings" "time" "github.com/aws/aws-sdk-go-v2/aws" iamsdk "github.com/aws/aws-sdk-go-v2/service/iam" "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/aws/smithy-go" "github.com/gocarina/gocsv" "github.com/elastic/cloudbeat/internal/resources/fetching" "github.com/elastic/cloudbeat/internal/resources/providers/awslib" "github.com/elastic/cloudbeat/internal/resources/utils/pointers" ) const ( rootAccount = "<root_account>" maxRetries = 5 interval = 3 * time.Second ) func (p Provider) GetUsers(ctx context.Context) ([]awslib.AwsResource, error) { apiUsers, err := p.listUsers(ctx) if err != nil { return nil, err } credentialReport, err := p.getCredentialReport(ctx) if err != nil { return nil, err } rootUser := p.getRootAccountUser(credentialReport[rootAccount]) if rootUser != nil { apiUsers = append(apiUsers, *rootUser) } users := make([]awslib.AwsResource, 0, len(apiUsers)) var userAccount *CredentialReport for _, apiUser := range apiUsers { var username string if apiUser.UserName != nil { username = *apiUser.UserName } var arn string if apiUser.Arn != nil { arn = *apiUser.Arn } keys := p.getUserKeys(*apiUser.UserName, credentialReport) if userAccount = credentialReport[aws.ToString(apiUser.UserName)]; userAccount == nil { continue } mfaDevices, err := p.getMFADevices(ctx, apiUser, userAccount) if err != nil { p.log.Errorf("fail to list mfa device for user: %s, error: %v", username, err) } pwdEnabled, err := isPasswordEnabled(userAccount) if err != nil { p.log.Errorf("fail to parse PasswordEnabled for user: %s, error: %v", username, err) pwdEnabled = false } inlinePolicies, err := p.listInlinePolicies(ctx, apiUser.UserName) if err != nil && !isRootUser(username) { p.log.Errorf("fail to list inline policies for user: %s, error: %v", username, err) } attachedPolicies, err := p.listAttachedPolicies(ctx, apiUser.UserName) if err != nil && !isRootUser(username) { p.log.Errorf("fail to list attached policies for user: %s, error: %v", username, err) } users = append(users, User{ AccessKeys: keys, MFADevices: mfaDevices, InlinePolicies: inlinePolicies, AttachedPolicies: attachedPolicies, Name: username, LastAccess: userAccount.PasswordLastUsed, Arn: arn, PasswordLastChanged: userAccount.PasswordLastChanged, PasswordEnabled: pwdEnabled, MfaActive: userAccount.MfaActive, UserId: pointers.Deref(apiUser.UserId), }) } return users, nil } func (u User) GetResourceArn() string { return u.Arn } func (u User) GetResourceName() string { return u.Name } func (u User) GetResourceType() string { return fetching.IAMUserType } func (u User) GetRegion() string { return awslib.GlobalRegion } func (p Provider) listUsers(ctx context.Context) ([]types.User, error) { p.log.Debug("IAMProvider.getUsers") var nativeUsers []types.User input := &iamsdk.ListUsersInput{} for { users, err := p.client.ListUsers(ctx, input) if err != nil { return nil, fmt.Errorf("failed to list users: %w", err) } nativeUsers = append(nativeUsers, users.Users...) if !users.IsTruncated { break } input.Marker = users.Marker } p.log.Debugf("IAMProvider.getUsers return %d users", len(nativeUsers)) return nativeUsers, nil } func (p Provider) getMFADevices(ctx context.Context, user types.User, userAccount *CredentialReport) ([]AuthDevice, error) { // For the root user, it's not possible to list all the devices, so instead we check all the virtual devices // to confirm if one is assigned the root user. If this is not the case, we can infer a hardware device is configured // (since we know MFA is active for the root user but cannot find a virtual device). if *user.UserName == rootAccount { return p.listRootMFADevice(ctx, userAccount) } return p.listMFADevices(ctx, user) } func (p Provider) listMFADevices(ctx context.Context, user types.User) ([]AuthDevice, error) { input := &iamsdk.ListMFADevicesInput{ UserName: user.UserName, } var apiDevices []types.MFADevice for { output, err := p.client.ListMFADevices(ctx, input) if err != nil { return nil, err } apiDevices = append(apiDevices, output.MFADevices...) if !output.IsTruncated { break } input.Marker = output.Marker } devices := make([]AuthDevice, 0, len(apiDevices)) for _, apiDevice := range apiDevices { isVirtual := true if !strings.HasPrefix(*apiDevice.SerialNumber, "arn:") { isVirtual = false } devices = append(devices, AuthDevice{ MFADevice: apiDevice, IsVirtual: isVirtual, }) } return devices, nil } func (p Provider) getUserKeys(username string, report map[string]*CredentialReport) []AccessKey { p.log.Debugf("aggregate access keys data for user: %s", username) entry := report[username] if entry == nil { p.log.Debugf("no entry for user: %s in credentials report", username) return nil } return []AccessKey{ { Active: entry.AccessKey1Active, LastAccess: entry.AccessKey1LastUsed, HasUsed: entry.AccessKey1LastUsed != "N/A", RotationDate: entry.AccessKey1LastRotated, }, { Active: entry.AccessKey2Active, LastAccess: entry.AccessKey2LastUsed, HasUsed: entry.AccessKey2LastUsed != "N/A", RotationDate: entry.AccessKey2LastRotated, }, } } //revive:disable-next-line:cognitive-complexity,cyclomatic func (p Provider) getCredentialReport(ctx context.Context) (map[string]*CredentialReport, error) { report, err := p.client.GetCredentialReport(ctx, &iamsdk.GetCredentialReportInput{}) if err != nil { var awsFailErr *types.ServiceFailureException if errors.As(err, &awsFailErr) { return nil, fmt.Errorf("could not gather aws iam credential report: %w", err) } // if we have an error, and it is not a server err we generate a report var apiErr smithy.APIError if errors.As(err, &apiErr) { if apiErr.ErrorCode() == "ReportNotPresent" || apiErr.ErrorCode() == "ReportExpired" { // generate a new report _, err = p.client.GenerateCredentialReport(ctx, &iamsdk.GenerateCredentialReportInput{}) if err != nil { return nil, fmt.Errorf("failed to generate credential report: %w", err) } } } // loop until max retries or till the report is ready countRetries := 0 report, err = p.client.GetCredentialReport(ctx, &iamsdk.GetCredentialReportInput{}) if errors.As(err, &apiErr) { for apiErr.ErrorCode() == "NoSuchEntity" || apiErr.ErrorCode() == "ReportInProgress" { if countRetries >= maxRetries { return nil, fmt.Errorf("reached max retries: %w", err) } report, err = p.client.GetCredentialReport(ctx, &iamsdk.GetCredentialReportInput{}) if err == nil { break } countRetries++ time.Sleep(interval) } } } if report == nil { if err != nil { return nil, fmt.Errorf("could not gather aws iam credential report: %w", err) } return nil, nil } parsedReport, err := parseCredentialsReport(report) if err != nil { return nil, fmt.Errorf("fail to parse credentials report: %w", err) } return parsedReport, nil } func parseCredentialsReport(report *iamsdk.GetCredentialReportOutput) (map[string]*CredentialReport, error) { var credentialReportCSV []*CredentialReport if err := gocsv.Unmarshal(bytes.NewReader(report.Content), &credentialReportCSV); err != nil { return nil, err } credentialReport := make(map[string]*CredentialReport) for i := range credentialReportCSV { credentialReport[credentialReportCSV[i].User] = credentialReportCSV[i] } return credentialReport, nil } func isPasswordEnabled(userAccount *CredentialReport) (bool, error) { if userAccount.PasswordEnabled == "not_supported" { return false, nil } return strconv.ParseBool(userAccount.PasswordEnabled) }