Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 116 additions & 13 deletions scrapers/aws/cloudtrail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"`
Expand All @@ -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{}
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 != "" {
Expand Down
111 changes: 111 additions & 0 deletions scrapers/aws/cloudtrail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
})
}
}
Loading