Skip to content
Open
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
39 changes: 34 additions & 5 deletions internal/inventory/asset.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
package inventory

import (
"time"

"github.com/go-viper/mapstructure/v2"
"github.com/samber/lo"
)
Expand Down Expand Up @@ -178,10 +180,11 @@ type URL struct {

// Entity contains the identifiers of the asset
type Entity struct {
Id string `json:"id"`
Name string `json:"name"`
Source *string `json:"source"`
Raw *any `json:"raw"`
Id string `json:"id"`
Name string `json:"name"`
Source *string `json:"source"`
Raw *any `json:"raw"`
Attributes map[string]any `json:"attributes,omitempty"`
AssetClassification

// non exported fields
Expand Down Expand Up @@ -226,7 +229,7 @@ type Host struct {
Name string `json:"name,omitempty"`
Architecture string `json:"architecture,omitempty"`
Type string `json:"type,omitempty"`
IP string `json:"ip,omitempty"`
IP []string `json:"ip,omitempty"`
MacAddress []string `json:"mac,omitempty"`
Entity *Entity `json:"entity,omitempty"`
}
Expand Down Expand Up @@ -445,3 +448,29 @@ func WithContainer(container Container) AssetEnricher {
a.Container = &container
}
}

// WithEntityAttributes sets non-ECS resource-specific attributes on the entity.
// Keys should use UpperCamelCase per the non-ECS field naming convention.
// A nil or empty map is a no-op.
func WithEntityAttributes(attrs map[string]any) AssetEnricher {
return func(a *AssetEvent) {
if len(attrs) == 0 {
return
}
a.Entity.Attributes = attrs
}
}

// WithCreatedAt sets the resource creation timestamp in entity.attributes["CreatedAt"].
// A nil time is a no-op.
func WithCreatedAt(t *time.Time) AssetEnricher {
return func(a *AssetEvent) {
if t == nil {
return
}
if a.Entity.Attributes == nil {
a.Entity.Attributes = make(map[string]any)
}
a.Entity.Attributes["CreatedAt"] = t
}
}
2 changes: 1 addition & 1 deletion internal/inventory/awsfetcher/awsfetchers.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func New(ctx context.Context, logger *clog.Logger, identity *cloud.Identity, cfg
snsProvider := sns.NewSNSProvider(ctx, logger, cfg, &awslib.MultiRegionClientFactory[sns.Client]{})

return []inventory.AssetFetcher{
newEc2InstancesFetcher(logger, identity, ec2Provider, statusHandler),
newEc2InstancesFetcher(logger, identity, ec2Provider, iamProvider, statusHandler),
newElbFetcher(logger, identity, elbProvider, elbv2Provider, statusHandler),
newIamPolicyFetcher(logger, identity, iamProvider, statusHandler),
newIamRoleFetcher(logger, identity, iamProvider, statusHandler),
Expand Down
136 changes: 130 additions & 6 deletions internal/inventory/awsfetcher/fetcher_ec2_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ package awsfetcher

import (
"context"
"strings"

iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types"

"github.com/elastic/cloudbeat/internal/dataprovider/providers/cloud"
"github.com/elastic/cloudbeat/internal/infra/clog"
Expand All @@ -32,6 +35,7 @@ import (
type ec2InstanceFetcher struct {
logger *clog.Logger
provider ec2InstancesProvider
iamResolver instanceProfileResolver
AccountId string
AccountName string
statusHandler statushandler.StatusHandlerAPI
Expand All @@ -41,10 +45,17 @@ type ec2InstancesProvider interface {
DescribeInstances(ctx context.Context) ([]*ec2.Ec2Instance, error)
}

func newEc2InstancesFetcher(logger *clog.Logger, identity *cloud.Identity, provider ec2InstancesProvider, statusHandler statushandler.StatusHandlerAPI) inventory.AssetFetcher {
// instanceProfileResolver resolves an IAM instance profile by name, returning
// the profile object (which contains the list of associated roles).
type instanceProfileResolver interface {
GetInstanceProfile(ctx context.Context, instanceProfileName string) (*iamtypes.InstanceProfile, error)
}

func newEc2InstancesFetcher(logger *clog.Logger, identity *cloud.Identity, provider ec2InstancesProvider, iamResolver instanceProfileResolver, statusHandler statushandler.StatusHandlerAPI) inventory.AssetFetcher {
return &ec2InstanceFetcher{
logger: logger,
provider: provider,
iamResolver: iamResolver,
AccountId: identity.Account,
AccountName: identity.AccountAlias,
statusHandler: statusHandler,
Expand All @@ -61,16 +72,33 @@ func (e *ec2InstanceFetcher) Fetch(ctx context.Context, assetChannel chan<- inve
awslib.ReportMissingPermission(e.statusHandler, err)
}

// Cache resolved role ARNs per profile ARN to avoid duplicate IAM calls.
roleArnCache := make(map[string]string)

for _, i := range instances {
if i == nil {
continue
}

tags := e.getTags(i)

// Resolve the IAM role ARN from the instance profile.
// IamInstanceProfile.Arn is the *instance-profile* ARN, not the role ARN.
// We emit InstanceProfileArn (accurate, free) and RoleArn (resolved via GetInstanceProfile).
iamFetcher := inventory.EmptyEnricher()
if i.IamInstanceProfile != nil {
iamFetcher = inventory.WithUser(inventory.User{
ID: pointers.Deref(i.IamInstanceProfile.Arn),
})
profileArn := pointers.Deref(i.IamInstanceProfile.Arn)
if profileArn != "" {
roleArn := e.resolveRoleArn(ctx, profileArn, roleArnCache)
// WithUser links this instance to the IAM Role asset (or falls back to the profile ARN).
userID := roleArn
if userID == "" {
userID = profileArn
}
iamFetcher = inventory.WithUser(inventory.User{
ID: userID,
})
}
}

assetChannel <- inventory.NewAssetEvent(
Expand All @@ -80,7 +108,7 @@ func (e *ec2InstanceFetcher) Fetch(ctx context.Context, assetChannel chan<- inve

inventory.WithRelatedAssetIds([]string{pointers.Deref(i.InstanceId)}),
inventory.WithRawAsset(i),
inventory.WithLabels(e.getTags(i)),
inventory.WithLabels(tags),
inventory.WithCloud(inventory.Cloud{
Provider: inventory.AwsCloudProvider,
Region: i.Region,
Expand All @@ -97,14 +125,110 @@ func (e *ec2InstanceFetcher) Fetch(ctx context.Context, assetChannel chan<- inve
Name: pointers.Deref(i.PrivateDnsName),
Architecture: string(i.Architecture),
Type: string(i.InstanceType),
IP: pointers.Deref(i.PublicIpAddress),
IP: buildIPs(i.PublicIpAddress, i.PrivateIpAddress),
MacAddress: i.GetResourceMacAddresses(),
}),
inventory.WithEntityAttributes(e.buildAttributes(i, tags, roleArnCache)),
inventory.WithCreatedAt(i.LaunchTime),
iamFetcher,
)
}
}

// resolveRoleArn attempts to resolve the IAM role ARN for the given instance-profile ARN.
// Results are cached in roleArnCache (keyed by profile ARN) so instances sharing a profile
// trigger only one IAM call. Returns "" if the profile has no roles or the call fails.
func (e *ec2InstanceFetcher) resolveRoleArn(ctx context.Context, profileArn string, roleArnCache map[string]string) string {
if cached, ok := roleArnCache[profileArn]; ok {
return cached
}

profileName := profileNameFromArn(profileArn)
if profileName == "" {
roleArnCache[profileArn] = ""
return ""
}

profile, err := e.iamResolver.GetInstanceProfile(ctx, profileName)
if err != nil {
e.logger.Warnf("Could not resolve IAM role for instance profile %s: %v", profileArn, err)
roleArnCache[profileArn] = ""
return ""
}

roleArn := ""
if len(profile.Roles) > 0 {
roleArn = pointers.Deref(profile.Roles[0].Arn)
}

roleArnCache[profileArn] = roleArn
return roleArn
}

// profileNameFromArn extracts the instance-profile name from its ARN.
// Example: "arn:aws:iam::123:instance-profile/MyProfile" → "MyProfile"
// Also handles path-qualified names: ".../instance-profile/division/MyProfile" → "division/MyProfile".
func profileNameFromArn(arn string) string {
const marker = "instance-profile/"
idx := strings.Index(arn, marker)
if idx == -1 {
return ""
}
return arn[idx+len(marker):]
}

// buildAttributes collects non-ECS, resource-specific EC2 fields into entity.attributes,
// using UpperCamelCase keys. Empty values are omitted so events stay clean and struct
// comparison in tests is stable.
func (e *ec2InstanceFetcher) buildAttributes(i *ec2.Ec2Instance, tags map[string]string, roleArnCache map[string]string) map[string]any {
attrs := map[string]any{}
if v := pointers.Deref(i.ImageId); v != "" {
attrs["ImageId"] = v
}
if v := string(i.Platform); v != "" {
attrs["Platform"] = v
}
if v := pointers.Deref(i.VpcId); v != "" {
attrs["VpcId"] = v
}
if v := pointers.Deref(i.SubnetId); v != "" {
attrs["SubnetId"] = v
}
if i.State != nil {
if v := string(i.State.Name); v != "" {
attrs["State"] = v
}
}
if i.IamInstanceProfile != nil {
if profileArn := pointers.Deref(i.IamInstanceProfile.Arn); profileArn != "" {
attrs["InstanceProfileArn"] = profileArn
if roleArn, ok := roleArnCache[profileArn]; ok && roleArn != "" {
attrs["RoleArn"] = roleArn
}
}
}
if v := awslib.LookupTag(tags, "owner"); v != "" {
attrs["Owner"] = v
}
if v := awslib.LookupTag(tags, "costcenter", "cost-center", "cost_center"); v != "" {
attrs["CostCenter"] = v
}
return attrs
}

// buildIPs collects non-empty IP address strings into a slice, returning nil when none exist.
// Using a nil (not empty) slice is important so that the json:"ip,omitempty" tag suppresses
// the field consistently and struct comparison in tests works without nil/empty mismatches.
func buildIPs(addrs ...*string) []string {
var ips []string
for _, addr := range addrs {
if v := pointers.Deref(addr); v != "" {
ips = append(ips, v)
}
}
return ips
}

func (e *ec2InstanceFetcher) getTags(instance *ec2.Ec2Instance) map[string]string {
tags := make(map[string]string, len(instance.Tags))
for _, t := range instance.Tags {
Expand Down
96 changes: 96 additions & 0 deletions internal/inventory/awsfetcher/fetcher_ec2_instance_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading