diff --git a/scrapers/aws/cloudtrail.go b/scrapers/aws/cloudtrail.go index ebded6fa5..0f65ad7d5 100644 --- a/scrapers/aws/cloudtrail.go +++ b/scrapers/aws/cloudtrail.go @@ -10,6 +10,9 @@ import ( "github.com/aws/aws-sdk-go-v2/service/cloudtrail" "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" "github.com/aws/smithy-go/ptr" + "github.com/flanksource/commons/hash" + dutyModels "github.com/flanksource/duty/models" + "github.com/lib/pq" "github.com/samber/lo" v1 "github.com/flanksource/config-db/api/v1" @@ -68,6 +71,7 @@ type CloudTrailEvent struct { RequestParameters struct { LogGroupName string `json:"logGroupName"` LogStreamName string `json:"logStreamName"` + RoleArn string `json:"roleArn"` } `json:"requestParameters"` Resources []struct { ARN string `json:"ARN"` @@ -86,10 +90,6 @@ func (aws Scraper) cloudtrail(ctx *AWSContext, config v1.AWS, results *v1.Scrape ctx.Logger.V(2).Infof("scraping cloudtrail") - if len(config.CloudTrail.Exclude) == 0 { - config.CloudTrail.Exclude = []string{"AssumeRole"} - } - var lastEventKey = ctx.Session.Region + *ctx.Caller.Account c := make(chan types.Event) wg := sync.WaitGroup{} @@ -102,12 +102,30 @@ func (aws Scraper) cloudtrail(ctx *AWSContext, config v1.AWS, results *v1.Scrape if event.EventTime != nil && event.EventTime.After(maxTime) { maxTime = *event.EventTime } + count++ if containsAny(config.CloudTrail.Exclude, *event.EventName) { ignored++ continue } + if lo.FromPtr(event.EventName) == "AssumeRole" { + // Certain cases return nil/nil + result, err := cloudtrailAssumeRoleToAccessLog(event) + if err != nil { + ctx.Logger.V(2).Infof("failed to convert AssumeRole event to access log: %v", err) + ignored++ + } else if result != nil { + *results = append(*results, *result) + } + continue + } + + // Ignore ReadOnly events other than AssumeRole + if lo.FromPtr(event.ReadOnly) == "true" { + continue + } + // If there's no resource, we add an empty resource as // we still want to have a change representing it. if len(event.Resources) == 0 { @@ -136,15 +154,8 @@ func (aws Scraper) cloudtrail(ctx *AWSContext, config v1.AWS, results *v1.Scrape if lastEventTime, ok := LastEventTime.Load(lastEventKey); ok { start = lastEventTime.(time.Time) } - err := lookupEvents(ctx, &cloudtrail.LookupEventsInput{ - StartTime: &start, - LookupAttributes: []types.LookupAttribute{ - { - AttributeKey: types.LookupAttributeKeyReadOnly, - AttributeValue: strPtr("false"), - }, - }, - }, c, config) + + err := lookupEvents(ctx, &cloudtrail.LookupEventsInput{StartTime: &start}, c, config) if err != nil { results.Errorf(err, "Failed to describe cloudtrail events") @@ -241,6 +252,98 @@ func cloudwatchLogStreamARN(event CloudTrailEvent) string { return fmt.Sprintf("arn:aws:logs:%s:%s:log-group:%s:log-stream:%s", region, accountID, logGroup, logStream) } +// cloudtrailAssumeRoleToAccessLog converts an AssumeRole CloudTrail event into +// a ScrapeResult containing the caller as an ExternalUser and an access log entry +// targeting the assumed role. +func cloudtrailAssumeRoleToAccessLog(event types.Event) (*v1.ScrapeResult, error) { + var ctEvent CloudTrailEvent + if err := ctEvent.FromJSON(ptr.ToString(event.CloudTrailEvent)); err != nil { + return nil, fmt.Errorf("error parsing cloudtrail event: %w", err) + } + + // Determine the assumed role ARN from request parameters or resources. + roleARN := ctEvent.RequestParameters.RoleArn + if roleARN == "" { + for _, r := range ctEvent.Resources { + if r.ARN != "" { + roleARN = r.ARN + break + } + } + } + if roleARN == "" { + return nil, fmt.Errorf("AssumeRole event has no role ARN") + } + + // Extract caller identity. + var userName, userARN, accountID string + userType := ctEvent.UserIdentity.Type + + // Ignore AWSService AssumeRole events + if userType == "AWSService" { + return nil, nil + } + + switch userType { + case "IAMUser": + userName = ctEvent.UserIdentity.Username + userARN = ctEvent.UserIdentity.Arn + accountID = ctEvent.UserIdentity.AccountID + case "AssumedRole": + userName = ctEvent.UserIdentity.SessionContext.SessionIssuer.Username + userARN = ctEvent.UserIdentity.SessionContext.SessionIssuer.Arn + accountID = ctEvent.UserIdentity.AccountID + if userARN == "" { + userARN = ctEvent.UserIdentity.Arn + } + if userName == "" { + userName = userARN + } + default: + userName = ctEvent.UserIdentity.Arn + userARN = ctEvent.UserIdentity.Arn + accountID = ctEvent.UserIdentity.AccountID + } + + if userARN == "" { + return nil, fmt.Errorf("AssumeRole event has no caller ARN") + } + + aliases := pq.StringArray{userARN} + userID, err := hash.DeterministicUUID(aliases) + if err != nil { + return nil, fmt.Errorf("error generating user id: %w", err) + } + externalUser := dutyModels.ExternalUser{ + ID: userID, + Name: userName, + Aliases: aliases, + AccountID: accountID, + UserType: userType, + } + + var eventTime time.Time + if event.EventTime != nil { + eventTime = *event.EventTime + } + + accessLog := v1.ExternalConfigAccessLog{ + ConfigAccessLog: dutyModels.ConfigAccessLog{ + ExternalUserID: userID, + CreatedAt: eventTime, + }, + ConfigExternalID: v1.ExternalID{ + ConfigType: "AWS::IAM::Role", + ExternalID: roleARN, + }, + } + + return &v1.ScrapeResult{ + ExternalUsers: []dutyModels.ExternalUser{externalUser}, + ConfigAccessLogs: []v1.ExternalConfigAccessLog{accessLog}, + }, nil +} + func cloudtrailEventToConfigType(resourceARN, eventSource string) string { service := "" if resourceARN != "" { diff --git a/scrapers/aws/cloudtrail_test.go b/scrapers/aws/cloudtrail_test.go index a5556b07e..2d995be35 100644 --- a/scrapers/aws/cloudtrail_test.go +++ b/scrapers/aws/cloudtrail_test.go @@ -3,8 +3,11 @@ package aws import ( "encoding/json" "testing" + "time" "github.com/aws/aws-sdk-go-v2/service/cloudtrail/types" + "github.com/flanksource/commons/hash" + "github.com/lib/pq" "github.com/onsi/gomega" "github.com/samber/lo" "gopkg.in/yaml.v3" @@ -202,3 +205,111 @@ requestParameters: }) } } + +func TestCloudTrailAssumeRoleToAccessLog(t *testing.T) { + eventTime := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + eventRaw string + expectedUserName string + expectedUserARN string + expectedUserAccountID string + expectedUserType string + expectedRoleARN string + expectedConfigType string + }{ + { + name: "IAM user assumes role", + eventRaw: `--- +userIdentity: + type: IAMUser + arn: arn:aws:iam::123456789012:user/admin + userName: admin + accountId: "123456789012" + principalId: AIDAEXAMPLE123 +requestParameters: + roleArn: arn:aws:iam::123456789012:role/MyRole + roleSessionName: my-session +resources: + - ARN: arn:aws:iam::123456789012:role/MyRole + accountId: "123456789012" +`, + expectedUserName: "admin", + expectedUserARN: "arn:aws:iam::123456789012:user/admin", + expectedUserAccountID: "123456789012", + expectedUserType: "IAMUser", + expectedRoleARN: "arn:aws:iam::123456789012:role/MyRole", + expectedConfigType: "AWS::IAM::Role", + }, + { + name: "AssumedRole assumes another role (role chaining)", + eventRaw: `--- +userIdentity: + type: AssumedRole + arn: arn:aws:sts::123456789012:assumed-role/IntermediateRole/session1 + accountId: "123456789012" + principalId: AROAEXAMPLE:session1 + sessionContext: + sessionIssuer: + arn: arn:aws:iam::123456789012:role/IntermediateRole + userName: IntermediateRole + accountId: "123456789012" +requestParameters: + roleArn: arn:aws:iam::987654321098:role/TargetRole +resources: + - ARN: arn:aws:iam::987654321098:role/TargetRole + accountId: "987654321098" +`, + expectedUserName: "IntermediateRole", + expectedUserARN: "arn:aws:iam::123456789012:role/IntermediateRole", + expectedUserAccountID: "123456789012", + expectedUserType: "AssumedRole", + expectedRoleARN: "arn:aws:iam::987654321098:role/TargetRole", + expectedConfigType: "AWS::IAM::Role", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := gomega.NewWithT(t) + + var eventMap map[string]any + err := yaml.Unmarshal([]byte(tt.eventRaw), &eventMap) + g.Expect(err).To(gomega.Succeed()) + + eventJSON, err := json.Marshal(eventMap) + g.Expect(err).To(gomega.Succeed()) + + event := types.Event{ + CloudTrailEvent: lo.ToPtr(string(eventJSON)), + EventTime: &eventTime, + EventName: lo.ToPtr("AssumeRole"), + } + + result, err := cloudtrailAssumeRoleToAccessLog(event) + g.Expect(err).To(gomega.Succeed()) + g.Expect(result).NotTo(gomega.BeNil()) + + // Verify ExternalUser + g.Expect(result.ExternalUsers).To(gomega.HaveLen(1)) + user := result.ExternalUsers[0] + g.Expect(user.Name).To(gomega.Equal(tt.expectedUserName)) + g.Expect(user.AccountID).To(gomega.Equal(tt.expectedUserAccountID)) + g.Expect(user.UserType).To(gomega.Equal(tt.expectedUserType)) + g.Expect(user.Aliases).To(gomega.ContainElement(tt.expectedUserARN)) + + expectedUserID, err := hash.DeterministicUUID(pq.StringArray{tt.expectedUserARN}) + g.Expect(err).To(gomega.Succeed()) + g.Expect(user.ID).To(gomega.Equal(expectedUserID)) + + // Verify ConfigAccessLog + g.Expect(result.ConfigAccessLogs).To(gomega.HaveLen(1)) + accessLog := result.ConfigAccessLogs[0] + g.Expect(accessLog.ConfigExternalID.ExternalID).To(gomega.Equal(tt.expectedRoleARN)) + g.Expect(accessLog.ConfigExternalID.ConfigType).To(gomega.Equal(tt.expectedConfigType)) + g.Expect(accessLog.ExternalUserID).To(gomega.Equal(expectedUserID)) + g.Expect(accessLog.CreatedAt).To(gomega.Equal(eventTime)) + }) + } +}