From c71ca0726985a7554e4d5a546c6738dd4f404855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Sobo=C5=84?= Date: Mon, 6 Jul 2026 18:26:57 +0200 Subject: [PATCH] [Cloud Asset Inventory] Add extra resource fields #1 (#6991) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to InfoSec's review of #6991. Adds the remaining EC2 and ELB fields InfoSec needs to retire their custom AWS CLI scripts for the FedRAMP asset inventory. All new fields land in `entity.attributes.*` (non-ECS, UpperCamelCase) — no ECS mapping change required. Tracking: https://github.com/elastic/security-team/issues/17750 ## Changes **EC2** (`entity.attributes`): - `ImageId`, `Platform`, `VpcId`, `SubnetId`, `State`, `RoleArn` - `Owner`, `CostCenter` — resolved case-insensitively from instance tags **ELB v1 + v2** (`entity.attributes`): - `LoadBalancerType` — v2 reports the real type; v1 reports `classic` - `AccountID` - `State` — v2 only (`State.Code`); classic ELB exposes no state field - `IPAddresses` — NLB static IPs; ALB/classic are DNS-only (empty) - `OwnerTag` — required adding `DescribeTags` to both ELB `Client` interfaces and providers (batched to the 20-item AWS limit) ## Notes - No `elastic/integrations` change: `entity.attributes` is `flattened`, so new keys need no mapping and there's no migration. - No new asset classifications, so `ASSETS.md` is unchanged. Route53 and EKS (also requested in the review) are separate follow-up PRs. - Mocks regenerated; unit tests cover EC2 attributes and the ELB Type/State/IPAddresses/OwnerTag flow end-to-end. --------- Co-authored-by: Claude Sonnet 4.6 (cherry picked from commit a25d3cb2f812cd8be10224027117d3713724aea7) --- internal/inventory/asset.go | 39 +- internal/inventory/awsfetcher/awsfetchers.go | 2 +- .../awsfetcher/fetcher_ec2_instance.go | 136 ++++++- .../awsfetcher/fetcher_ec2_instance_mock.go | 96 +++++ .../awsfetcher/fetcher_ec2_instance_test.go | 160 +++++++-- internal/inventory/awsfetcher/fetcher_elb.go | 40 +++ .../inventory/awsfetcher/fetcher_elb_mock.go | 340 ++++++++++++++++++ .../inventory/awsfetcher/fetcher_elb_test.go | 14 + .../awsfetcher/fetcher_iam_policy.go | 1 + .../awsfetcher/fetcher_iam_policy_test.go | 1 + .../inventory/awsfetcher/fetcher_iam_role.go | 1 + .../awsfetcher/fetcher_iam_role_test.go | 2 + .../inventory/awsfetcher/fetcher_iam_user.go | 1 + internal/inventory/awsfetcher/fetcher_rds.go | 12 + .../inventory/awsfetcher/fetcher_rds_test.go | 19 + .../inventory/awsfetcher/fetcher_s3_bucket.go | 1 + .../azurefetcher/fetcher_activedirectory.go | 2 + .../azurefetcher/fetcher_resource_graph.go | 27 ++ .../inventory/gcpfetcher/fetcher_assets.go | 24 +- .../resources/providers/awslib/elb/elb.go | 1 + .../providers/awslib/elb/elb_mock.go | 83 +++++ .../providers/awslib/elb/load_balancer.go | 36 ++ .../providers/awslib/elb/provider.go | 38 ++ .../providers/awslib/elb/provider_test.go | 16 + .../providers/awslib/elb_v2/elb_v2.go | 1 + .../providers/awslib/elb_v2/elb_v2_mock.go | 83 +++++ .../awslib/elb_v2/load_balancer_v2.go | 48 +++ .../providers/awslib/elb_v2/provider_v2.go | 44 ++- .../awslib/elb_v2/provider_v2_test.go | 32 +- .../resources/providers/awslib/iam/iam.go | 3 + .../providers/awslib/iam/iam_mock.go | 83 +++++ .../providers/awslib/iam/instance_profile.go | 50 +++ .../awslib/iam/instance_profile_mock.go | 125 +++++++ .../resources/providers/awslib/iam/user.go | 1 + .../providers/awslib/rds/provider.go | 3 + .../resources/providers/awslib/rds/rds.go | 16 +- .../providers/awslib/role_chaining_mock.go | 109 ++++++ .../resources/providers/awslib/s3/provider.go | 1 + internal/resources/providers/awslib/s3/s3.go | 2 + internal/resources/providers/awslib/tags.go | 33 ++ 40 files changed, 1671 insertions(+), 55 deletions(-) create mode 100644 internal/resources/providers/awslib/iam/instance_profile.go create mode 100644 internal/resources/providers/awslib/iam/instance_profile_mock.go create mode 100644 internal/resources/providers/awslib/role_chaining_mock.go create mode 100644 internal/resources/providers/awslib/tags.go diff --git a/internal/inventory/asset.go b/internal/inventory/asset.go index 7871a3825e..9c1e95efa7 100644 --- a/internal/inventory/asset.go +++ b/internal/inventory/asset.go @@ -18,6 +18,8 @@ package inventory import ( + "time" + "github.com/go-viper/mapstructure/v2" "github.com/samber/lo" ) @@ -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 @@ -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"` } @@ -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 + } +} diff --git a/internal/inventory/awsfetcher/awsfetchers.go b/internal/inventory/awsfetcher/awsfetchers.go index 5f0a6d7f21..7517df5bb7 100644 --- a/internal/inventory/awsfetcher/awsfetchers.go +++ b/internal/inventory/awsfetcher/awsfetchers.go @@ -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), diff --git a/internal/inventory/awsfetcher/fetcher_ec2_instance.go b/internal/inventory/awsfetcher/fetcher_ec2_instance.go index e99bf97d5a..a81dfd537a 100644 --- a/internal/inventory/awsfetcher/fetcher_ec2_instance.go +++ b/internal/inventory/awsfetcher/fetcher_ec2_instance.go @@ -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" @@ -32,6 +35,7 @@ import ( type ec2InstanceFetcher struct { logger *clog.Logger provider ec2InstancesProvider + iamResolver instanceProfileResolver AccountId string AccountName string statusHandler statushandler.StatusHandlerAPI @@ -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, @@ -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( @@ -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, @@ -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 { diff --git a/internal/inventory/awsfetcher/fetcher_ec2_instance_mock.go b/internal/inventory/awsfetcher/fetcher_ec2_instance_mock.go index d1fc46c7ad..744ac5402b 100644 --- a/internal/inventory/awsfetcher/fetcher_ec2_instance_mock.go +++ b/internal/inventory/awsfetcher/fetcher_ec2_instance_mock.go @@ -25,6 +25,7 @@ package awsfetcher import ( "context" + "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/elastic/cloudbeat/internal/resources/providers/awslib/ec2" mock "github.com/stretchr/testify/mock" ) @@ -117,3 +118,98 @@ func (_c *mockEc2InstancesProvider_DescribeInstances_Call) RunAndReturn(run func _c.Call.Return(run) return _c } + +// newMockInstanceProfileResolver creates a new instance of mockInstanceProfileResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockInstanceProfileResolver(t interface { + mock.TestingT + Cleanup(func()) +}) *mockInstanceProfileResolver { + mock := &mockInstanceProfileResolver{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// mockInstanceProfileResolver is an autogenerated mock type for the instanceProfileResolver type +type mockInstanceProfileResolver struct { + mock.Mock +} + +type mockInstanceProfileResolver_Expecter struct { + mock *mock.Mock +} + +func (_m *mockInstanceProfileResolver) EXPECT() *mockInstanceProfileResolver_Expecter { + return &mockInstanceProfileResolver_Expecter{mock: &_m.Mock} +} + +// GetInstanceProfile provides a mock function for the type mockInstanceProfileResolver +func (_mock *mockInstanceProfileResolver) GetInstanceProfile(ctx context.Context, instanceProfileName string) (*types.InstanceProfile, error) { + ret := _mock.Called(ctx, instanceProfileName) + + if len(ret) == 0 { + panic("no return value specified for GetInstanceProfile") + } + + var r0 *types.InstanceProfile + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*types.InstanceProfile, error)); ok { + return returnFunc(ctx, instanceProfileName) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *types.InstanceProfile); ok { + r0 = returnFunc(ctx, instanceProfileName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.InstanceProfile) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, instanceProfileName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// mockInstanceProfileResolver_GetInstanceProfile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstanceProfile' +type mockInstanceProfileResolver_GetInstanceProfile_Call struct { + *mock.Call +} + +// GetInstanceProfile is a helper method to define mock.On call +// - ctx context.Context +// - instanceProfileName string +func (_e *mockInstanceProfileResolver_Expecter) GetInstanceProfile(ctx interface{}, instanceProfileName interface{}) *mockInstanceProfileResolver_GetInstanceProfile_Call { + return &mockInstanceProfileResolver_GetInstanceProfile_Call{Call: _e.mock.On("GetInstanceProfile", ctx, instanceProfileName)} +} + +func (_c *mockInstanceProfileResolver_GetInstanceProfile_Call) Run(run func(ctx context.Context, instanceProfileName string)) *mockInstanceProfileResolver_GetInstanceProfile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *mockInstanceProfileResolver_GetInstanceProfile_Call) Return(instanceProfile *types.InstanceProfile, err error) *mockInstanceProfileResolver_GetInstanceProfile_Call { + _c.Call.Return(instanceProfile, err) + return _c +} + +func (_c *mockInstanceProfileResolver_GetInstanceProfile_Call) RunAndReturn(run func(ctx context.Context, instanceProfileName string) (*types.InstanceProfile, error)) *mockInstanceProfileResolver_GetInstanceProfile_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/inventory/awsfetcher/fetcher_ec2_instance_test.go b/internal/inventory/awsfetcher/fetcher_ec2_instance_test.go index c36f2adae2..b4d022b025 100644 --- a/internal/inventory/awsfetcher/fetcher_ec2_instance_test.go +++ b/internal/inventory/awsfetcher/fetcher_ec2_instance_test.go @@ -18,9 +18,12 @@ package awsfetcher import ( + "errors" "testing" + "time" - "github.com/aws/aws-sdk-go-v2/service/ec2/types" + ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" "github.com/stretchr/testify/mock" "github.com/elastic/cloudbeat/internal/dataprovider/providers/cloud" @@ -32,58 +35,68 @@ import ( "github.com/elastic/cloudbeat/internal/statushandler" ) -func TestEC2InstanceFetcher_Fetch(t *testing.T) { - instance1 := &ec2beat.Ec2Instance{ - Instance: types.Instance{ - IamInstanceProfile: &types.IamInstanceProfile{ +const ( + testProfileArn = "arn:aws:iam::123456789012:instance-profile/test-profile" + testRoleArn = "arn:aws:iam::123456789012:role/test-role" +) + +func makeTestInstance(launchTime *time.Time) *ec2beat.Ec2Instance { + return &ec2beat.Ec2Instance{ + Instance: ec2types.Instance{ + IamInstanceProfile: &ec2types.IamInstanceProfile{ Id: pointers.Ref("a123123"), - Arn: pointers.Ref("123123:123123:123123"), + Arn: pointers.Ref(testProfileArn), }, - Tags: []types.Tag{ - { - Key: pointers.Ref("Name"), - Value: pointers.Ref("test-server"), - }, - { - Key: pointers.Ref("key"), - Value: pointers.Ref("value"), - }, + Tags: []ec2types.Tag{ + {Key: pointers.Ref("Name"), Value: pointers.Ref("test-server")}, + {Key: pointers.Ref("key"), Value: pointers.Ref("value")}, + {Key: pointers.Ref("Owner"), Value: pointers.Ref("team-infra")}, + {Key: pointers.Ref("CostCenter"), Value: pointers.Ref("cc-1234")}, }, InstanceId: pointers.Ref("234567890"), - Architecture: types.ArchitectureValuesX8664, + Architecture: ec2types.ArchitectureValuesX8664, ImageId: pointers.Ref("image-id"), InstanceType: "instance-type", Platform: "linux", PlatformDetails: pointers.Ref("ubuntu"), VpcId: pointers.Ref("vpc-id"), SubnetId: pointers.Ref("subnetId"), + State: &ec2types.InstanceState{Name: ec2types.InstanceStateNameRunning}, Ipv6Address: pointers.Ref("ipv6"), PublicIpAddress: pointers.Ref("public-ip-addr"), PrivateIpAddress: pointers.Ref("private-ip-addre"), + LaunchTime: launchTime, PublicDnsName: pointers.Ref("public-dns"), PrivateDnsName: pointers.Ref("private-dns"), - Placement: &types.Placement{ - AvailabilityZone: pointers.Ref("1a"), - }, - NetworkInterfaces: []types.InstanceNetworkInterface{ - { - MacAddress: pointers.Ref("mac1"), - }, - { - MacAddress: pointers.Ref("mac2"), - }, + Placement: &ec2types.Placement{AvailabilityZone: pointers.Ref("1a")}, + NetworkInterfaces: []ec2types.InstanceNetworkInterface{ + {MacAddress: pointers.Ref("mac1")}, + {MacAddress: pointers.Ref("mac2")}, }, }, Region: "us-east", } +} + +func TestEC2InstanceFetcher_Fetch(t *testing.T) { + launchTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + instance1 := makeTestInstance(&launchTime) instance2 := &ec2beat.Ec2Instance{ - Instance: types.Instance{}, + Instance: ec2types.Instance{}, Region: "us-east", } in := []*ec2beat.Ec2Instance{instance1, nil, instance2} + resolvedProfile := &iamtypes.InstanceProfile{ + InstanceProfileName: pointers.Ref("test-profile"), + Arn: pointers.Ref(testProfileArn), + Roles: []iamtypes.Role{ + {Arn: pointers.Ref(testRoleArn)}, + }, + } + expected := []inventory.AssetEvent{ inventory.NewAssetEvent( inventory.AssetClassificationAwsEc2Instance, @@ -91,7 +104,7 @@ func TestEC2InstanceFetcher_Fetch(t *testing.T) { "private-dns", inventory.WithRelatedAssetIds([]string{"234567890"}), inventory.WithRawAsset(instance1), - inventory.WithLabels(map[string]string{"Name": "test-server", "key": "value"}), + inventory.WithLabels(map[string]string{"Name": "test-server", "key": "value", "Owner": "team-infra", "CostCenter": "cc-1234"}), inventory.WithCloud(inventory.Cloud{ Provider: inventory.AwsCloudProvider, Region: "us-east", @@ -106,13 +119,25 @@ func TestEC2InstanceFetcher_Fetch(t *testing.T) { inventory.WithHost(inventory.Host{ ID: "234567890", Name: "private-dns", - Architecture: string(types.ArchitectureValuesX8664), + Architecture: string(ec2types.ArchitectureValuesX8664), Type: "instance-type", - IP: "public-ip-addr", + IP: []string{"public-ip-addr", "private-ip-addre"}, MacAddress: []string{"mac1", "mac2"}, }), + inventory.WithEntityAttributes(map[string]any{ + "ImageId": "image-id", + "Platform": "linux", + "VpcId": "vpc-id", + "SubnetId": "subnetId", + "State": "running", + "InstanceProfileArn": testProfileArn, + "RoleArn": testRoleArn, + "Owner": "team-infra", + "CostCenter": "cc-1234", + }), + inventory.WithCreatedAt(&launchTime), inventory.WithUser(inventory.User{ - ID: "123123:123123:123123", + ID: testRoleArn, }), ), @@ -143,10 +168,81 @@ func TestEC2InstanceFetcher_Fetch(t *testing.T) { provider := newMockEc2InstancesProvider(t) provider.EXPECT().DescribeInstances(mock.Anything).Return(in, nil) + resolver := newMockInstanceProfileResolver(t) + resolver.EXPECT().GetInstanceProfile(mock.Anything, "test-profile").Return(resolvedProfile, nil) + + msh := statushandler.NewMockStatusHandlerAPI(t) + + identity := &cloud.Identity{Account: "123", AccountAlias: "alias"} + fetcher := newEc2InstancesFetcher(logger, identity, provider, resolver, msh) + + testutil.CollectResourcesAndMatch(t, fetcher, expected) +} + +func TestEC2InstanceFetcher_Fetch_ResolverError(t *testing.T) { + launchTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC) + + instance := makeTestInstance(&launchTime) + in := []*ec2beat.Ec2Instance{instance} + + // When the resolver fails, InstanceProfileArn is emitted but RoleArn is not. + expected := []inventory.AssetEvent{ + inventory.NewAssetEvent( + inventory.AssetClassificationAwsEc2Instance, + "arn:aws:ec2:us-east::ec2/234567890", + "private-dns", + inventory.WithRelatedAssetIds([]string{"234567890"}), + inventory.WithRawAsset(instance), + inventory.WithLabels(map[string]string{"Name": "test-server", "key": "value", "Owner": "team-infra", "CostCenter": "cc-1234"}), + inventory.WithCloud(inventory.Cloud{ + Provider: inventory.AwsCloudProvider, + Region: "us-east", + AvailabilityZone: "1a", + AccountID: "123", + AccountName: "alias", + InstanceID: "234567890", + InstanceName: "test-server", + MachineType: "instance-type", + ServiceName: "AWS EC2", + }), + inventory.WithHost(inventory.Host{ + ID: "234567890", + Name: "private-dns", + Architecture: string(ec2types.ArchitectureValuesX8664), + Type: "instance-type", + IP: []string{"public-ip-addr", "private-ip-addre"}, + MacAddress: []string{"mac1", "mac2"}, + }), + inventory.WithEntityAttributes(map[string]any{ + "ImageId": "image-id", + "Platform": "linux", + "VpcId": "vpc-id", + "SubnetId": "subnetId", + "State": "running", + "InstanceProfileArn": testProfileArn, + // RoleArn is absent because the resolver failed. + "Owner": "team-infra", + "CostCenter": "cc-1234", + }), + inventory.WithCreatedAt(&launchTime), + // WithUser falls back to the profile ARN when role resolution fails. + inventory.WithUser(inventory.User{ + ID: testProfileArn, + }), + ), + } + + logger := testhelper.NewLogger(t) + provider := newMockEc2InstancesProvider(t) + provider.EXPECT().DescribeInstances(mock.Anything).Return(in, nil) + + resolver := newMockInstanceProfileResolver(t) + resolver.EXPECT().GetInstanceProfile(mock.Anything, "test-profile").Return(nil, errors.New("AccessDenied")) + msh := statushandler.NewMockStatusHandlerAPI(t) identity := &cloud.Identity{Account: "123", AccountAlias: "alias"} - fetcher := newEc2InstancesFetcher(logger, identity, provider, msh) + fetcher := newEc2InstancesFetcher(logger, identity, provider, resolver, msh) testutil.CollectResourcesAndMatch(t, fetcher, expected) } diff --git a/internal/inventory/awsfetcher/fetcher_elb.go b/internal/inventory/awsfetcher/fetcher_elb.go index 8669ebc0e7..18c57c82e8 100644 --- a/internal/inventory/awsfetcher/fetcher_elb.go +++ b/internal/inventory/awsfetcher/fetcher_elb.go @@ -19,6 +19,7 @@ package awsfetcher import ( "context" + "time" "github.com/elastic/cloudbeat/internal/dataprovider/providers/cloud" "github.com/elastic/cloudbeat/internal/infra/clog" @@ -27,6 +28,18 @@ import ( "github.com/elastic/cloudbeat/internal/statushandler" ) +// elbInventoryResource is satisfied by both the v1 and v2 ELB wrapper types, +// giving the fetcher access to fields that are not part of the awslib.AwsResource interface. +type elbInventoryResource interface { + GetDNSName() string + IsPubliclyAccessible() bool + GetCreatedAt() *time.Time + GetLoadBalancerType() string + GetState() string + GetIPAddresses() []string + GetOwnerTag() string +} + type elbFetcher struct { logger *clog.Logger v1 v1Provider @@ -81,6 +94,31 @@ func (f *elbFetcher) fetch(ctx context.Context, resourceName string, function el } for _, item := range awsResources { + var attrs map[string]any + var createdAt *time.Time + if r, ok := item.(elbInventoryResource); ok { + attrs = map[string]any{ + "DNSName": r.GetDNSName(), + "PubliclyAccessible": r.IsPubliclyAccessible(), + } + if f.AccountId != "" { + attrs["AccountID"] = f.AccountId + } + if v := r.GetLoadBalancerType(); v != "" { + attrs["LoadBalancerType"] = v + } + if v := r.GetState(); v != "" { + attrs["State"] = v + } + if v := r.GetIPAddresses(); len(v) > 0 { + attrs["IPAddresses"] = v + } + if v := r.GetOwnerTag(); v != "" { + attrs["OwnerTag"] = v + } + createdAt = r.GetCreatedAt() + } + assetChannel <- inventory.NewAssetEvent( classification, item.GetResourceArn(), @@ -93,6 +131,8 @@ func (f *elbFetcher) fetch(ctx context.Context, resourceName string, function el AccountName: f.AccountName, ServiceName: "AWS Networking", }), + inventory.WithEntityAttributes(attrs), + inventory.WithCreatedAt(createdAt), ) } } diff --git a/internal/inventory/awsfetcher/fetcher_elb_mock.go b/internal/inventory/awsfetcher/fetcher_elb_mock.go index a539048c60..63afb07ca8 100644 --- a/internal/inventory/awsfetcher/fetcher_elb_mock.go +++ b/internal/inventory/awsfetcher/fetcher_elb_mock.go @@ -24,11 +24,351 @@ package awsfetcher import ( "context" + "time" "github.com/elastic/cloudbeat/internal/resources/providers/awslib" mock "github.com/stretchr/testify/mock" ) +// newMockElbInventoryResource creates a new instance of mockElbInventoryResource. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockElbInventoryResource(t interface { + mock.TestingT + Cleanup(func()) +}) *mockElbInventoryResource { + mock := &mockElbInventoryResource{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// mockElbInventoryResource is an autogenerated mock type for the elbInventoryResource type +type mockElbInventoryResource struct { + mock.Mock +} + +type mockElbInventoryResource_Expecter struct { + mock *mock.Mock +} + +func (_m *mockElbInventoryResource) EXPECT() *mockElbInventoryResource_Expecter { + return &mockElbInventoryResource_Expecter{mock: &_m.Mock} +} + +// GetCreatedAt provides a mock function for the type mockElbInventoryResource +func (_mock *mockElbInventoryResource) GetCreatedAt() *time.Time { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetCreatedAt") + } + + var r0 *time.Time + if returnFunc, ok := ret.Get(0).(func() *time.Time); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*time.Time) + } + } + return r0 +} + +// mockElbInventoryResource_GetCreatedAt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCreatedAt' +type mockElbInventoryResource_GetCreatedAt_Call struct { + *mock.Call +} + +// GetCreatedAt is a helper method to define mock.On call +func (_e *mockElbInventoryResource_Expecter) GetCreatedAt() *mockElbInventoryResource_GetCreatedAt_Call { + return &mockElbInventoryResource_GetCreatedAt_Call{Call: _e.mock.On("GetCreatedAt")} +} + +func (_c *mockElbInventoryResource_GetCreatedAt_Call) Run(run func()) *mockElbInventoryResource_GetCreatedAt_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockElbInventoryResource_GetCreatedAt_Call) Return(time1 *time.Time) *mockElbInventoryResource_GetCreatedAt_Call { + _c.Call.Return(time1) + return _c +} + +func (_c *mockElbInventoryResource_GetCreatedAt_Call) RunAndReturn(run func() *time.Time) *mockElbInventoryResource_GetCreatedAt_Call { + _c.Call.Return(run) + return _c +} + +// GetDNSName provides a mock function for the type mockElbInventoryResource +func (_mock *mockElbInventoryResource) GetDNSName() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetDNSName") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// mockElbInventoryResource_GetDNSName_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDNSName' +type mockElbInventoryResource_GetDNSName_Call struct { + *mock.Call +} + +// GetDNSName is a helper method to define mock.On call +func (_e *mockElbInventoryResource_Expecter) GetDNSName() *mockElbInventoryResource_GetDNSName_Call { + return &mockElbInventoryResource_GetDNSName_Call{Call: _e.mock.On("GetDNSName")} +} + +func (_c *mockElbInventoryResource_GetDNSName_Call) Run(run func()) *mockElbInventoryResource_GetDNSName_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockElbInventoryResource_GetDNSName_Call) Return(s string) *mockElbInventoryResource_GetDNSName_Call { + _c.Call.Return(s) + return _c +} + +func (_c *mockElbInventoryResource_GetDNSName_Call) RunAndReturn(run func() string) *mockElbInventoryResource_GetDNSName_Call { + _c.Call.Return(run) + return _c +} + +// GetIPAddresses provides a mock function for the type mockElbInventoryResource +func (_mock *mockElbInventoryResource) GetIPAddresses() []string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetIPAddresses") + } + + var r0 []string + if returnFunc, ok := ret.Get(0).(func() []string); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + return r0 +} + +// mockElbInventoryResource_GetIPAddresses_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetIPAddresses' +type mockElbInventoryResource_GetIPAddresses_Call struct { + *mock.Call +} + +// GetIPAddresses is a helper method to define mock.On call +func (_e *mockElbInventoryResource_Expecter) GetIPAddresses() *mockElbInventoryResource_GetIPAddresses_Call { + return &mockElbInventoryResource_GetIPAddresses_Call{Call: _e.mock.On("GetIPAddresses")} +} + +func (_c *mockElbInventoryResource_GetIPAddresses_Call) Run(run func()) *mockElbInventoryResource_GetIPAddresses_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockElbInventoryResource_GetIPAddresses_Call) Return(strings []string) *mockElbInventoryResource_GetIPAddresses_Call { + _c.Call.Return(strings) + return _c +} + +func (_c *mockElbInventoryResource_GetIPAddresses_Call) RunAndReturn(run func() []string) *mockElbInventoryResource_GetIPAddresses_Call { + _c.Call.Return(run) + return _c +} + +// GetLoadBalancerType provides a mock function for the type mockElbInventoryResource +func (_mock *mockElbInventoryResource) GetLoadBalancerType() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetLoadBalancerType") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// mockElbInventoryResource_GetLoadBalancerType_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLoadBalancerType' +type mockElbInventoryResource_GetLoadBalancerType_Call struct { + *mock.Call +} + +// GetLoadBalancerType is a helper method to define mock.On call +func (_e *mockElbInventoryResource_Expecter) GetLoadBalancerType() *mockElbInventoryResource_GetLoadBalancerType_Call { + return &mockElbInventoryResource_GetLoadBalancerType_Call{Call: _e.mock.On("GetLoadBalancerType")} +} + +func (_c *mockElbInventoryResource_GetLoadBalancerType_Call) Run(run func()) *mockElbInventoryResource_GetLoadBalancerType_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockElbInventoryResource_GetLoadBalancerType_Call) Return(s string) *mockElbInventoryResource_GetLoadBalancerType_Call { + _c.Call.Return(s) + return _c +} + +func (_c *mockElbInventoryResource_GetLoadBalancerType_Call) RunAndReturn(run func() string) *mockElbInventoryResource_GetLoadBalancerType_Call { + _c.Call.Return(run) + return _c +} + +// GetOwnerTag provides a mock function for the type mockElbInventoryResource +func (_mock *mockElbInventoryResource) GetOwnerTag() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetOwnerTag") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// mockElbInventoryResource_GetOwnerTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetOwnerTag' +type mockElbInventoryResource_GetOwnerTag_Call struct { + *mock.Call +} + +// GetOwnerTag is a helper method to define mock.On call +func (_e *mockElbInventoryResource_Expecter) GetOwnerTag() *mockElbInventoryResource_GetOwnerTag_Call { + return &mockElbInventoryResource_GetOwnerTag_Call{Call: _e.mock.On("GetOwnerTag")} +} + +func (_c *mockElbInventoryResource_GetOwnerTag_Call) Run(run func()) *mockElbInventoryResource_GetOwnerTag_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockElbInventoryResource_GetOwnerTag_Call) Return(s string) *mockElbInventoryResource_GetOwnerTag_Call { + _c.Call.Return(s) + return _c +} + +func (_c *mockElbInventoryResource_GetOwnerTag_Call) RunAndReturn(run func() string) *mockElbInventoryResource_GetOwnerTag_Call { + _c.Call.Return(run) + return _c +} + +// GetState provides a mock function for the type mockElbInventoryResource +func (_mock *mockElbInventoryResource) GetState() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for GetState") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// mockElbInventoryResource_GetState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetState' +type mockElbInventoryResource_GetState_Call struct { + *mock.Call +} + +// GetState is a helper method to define mock.On call +func (_e *mockElbInventoryResource_Expecter) GetState() *mockElbInventoryResource_GetState_Call { + return &mockElbInventoryResource_GetState_Call{Call: _e.mock.On("GetState")} +} + +func (_c *mockElbInventoryResource_GetState_Call) Run(run func()) *mockElbInventoryResource_GetState_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockElbInventoryResource_GetState_Call) Return(s string) *mockElbInventoryResource_GetState_Call { + _c.Call.Return(s) + return _c +} + +func (_c *mockElbInventoryResource_GetState_Call) RunAndReturn(run func() string) *mockElbInventoryResource_GetState_Call { + _c.Call.Return(run) + return _c +} + +// IsPubliclyAccessible provides a mock function for the type mockElbInventoryResource +func (_mock *mockElbInventoryResource) IsPubliclyAccessible() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for IsPubliclyAccessible") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// mockElbInventoryResource_IsPubliclyAccessible_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsPubliclyAccessible' +type mockElbInventoryResource_IsPubliclyAccessible_Call struct { + *mock.Call +} + +// IsPubliclyAccessible is a helper method to define mock.On call +func (_e *mockElbInventoryResource_Expecter) IsPubliclyAccessible() *mockElbInventoryResource_IsPubliclyAccessible_Call { + return &mockElbInventoryResource_IsPubliclyAccessible_Call{Call: _e.mock.On("IsPubliclyAccessible")} +} + +func (_c *mockElbInventoryResource_IsPubliclyAccessible_Call) Run(run func()) *mockElbInventoryResource_IsPubliclyAccessible_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockElbInventoryResource_IsPubliclyAccessible_Call) Return(b bool) *mockElbInventoryResource_IsPubliclyAccessible_Call { + _c.Call.Return(b) + return _c +} + +func (_c *mockElbInventoryResource_IsPubliclyAccessible_Call) RunAndReturn(run func() bool) *mockElbInventoryResource_IsPubliclyAccessible_Call { + _c.Call.Return(run) + return _c +} + // newMockV1Provider creates a new instance of mockV1Provider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func newMockV1Provider(t interface { diff --git a/internal/inventory/awsfetcher/fetcher_elb_test.go b/internal/inventory/awsfetcher/fetcher_elb_test.go index cb384b4150..eee28cc2fd 100644 --- a/internal/inventory/awsfetcher/fetcher_elb_test.go +++ b/internal/inventory/awsfetcher/fetcher_elb_test.go @@ -79,6 +79,13 @@ func TestELBv1Fetcher_Fetch(t *testing.T) { AccountName: "alias", ServiceName: "AWS Networking", }), + inventory.WithEntityAttributes(map[string]any{ + "DNSName": "internal-my-elb-v1.us-east-1.elb.amazonaws.com", + "PubliclyAccessible": false, // scheme is "internal" + "AccountID": "123", + "LoadBalancerType": "classic", + }), + inventory.WithCreatedAt(asset.GetCreatedAt()), ), } @@ -127,6 +134,13 @@ func TestELBv2Fetcher_Fetch(t *testing.T) { AccountName: "alias", ServiceName: "AWS Networking", }), + inventory.WithEntityAttributes(map[string]any{ + "DNSName": "internal-my-elb-v2.us-east-1.elb.amazonaws.com", + "PubliclyAccessible": false, // scheme is internal + "AccountID": "123", + "LoadBalancerType": "application", + }), + inventory.WithCreatedAt(asset.GetCreatedAt()), ), } diff --git a/internal/inventory/awsfetcher/fetcher_iam_policy.go b/internal/inventory/awsfetcher/fetcher_iam_policy.go index 11045debba..9778d343ea 100644 --- a/internal/inventory/awsfetcher/fetcher_iam_policy.go +++ b/internal/inventory/awsfetcher/fetcher_iam_policy.go @@ -90,6 +90,7 @@ func (i *iamPolicyFetcher) Fetch(ctx context.Context, assetChannel chan<- invent AccountName: i.AccountName, ServiceName: "AWS IAM", }), + inventory.WithCreatedAt(policy.CreateDate), ) } } diff --git a/internal/inventory/awsfetcher/fetcher_iam_policy_test.go b/internal/inventory/awsfetcher/fetcher_iam_policy_test.go index 95082266a6..a905212f37 100644 --- a/internal/inventory/awsfetcher/fetcher_iam_policy_test.go +++ b/internal/inventory/awsfetcher/fetcher_iam_policy_test.go @@ -123,6 +123,7 @@ func TestIAMPolicyFetcher_Fetch(t *testing.T) { "key-1": "value-1", "key-2": "value-2", }), + inventory.WithCreatedAt(&now), ), inventory.NewAssetEvent( diff --git a/internal/inventory/awsfetcher/fetcher_iam_role.go b/internal/inventory/awsfetcher/fetcher_iam_role.go index d18666ac7b..486e6e89e1 100644 --- a/internal/inventory/awsfetcher/fetcher_iam_role.go +++ b/internal/inventory/awsfetcher/fetcher_iam_role.go @@ -87,6 +87,7 @@ func (i *iamRoleFetcher) Fetch(ctx context.Context, assetChannel chan<- inventor ID: pointers.Deref(role.Arn), Name: pointers.Deref(role.RoleName), }), + inventory.WithCreatedAt(role.CreateDate), ) } } diff --git a/internal/inventory/awsfetcher/fetcher_iam_role_test.go b/internal/inventory/awsfetcher/fetcher_iam_role_test.go index 1e8c36405d..bacd4356e1 100644 --- a/internal/inventory/awsfetcher/fetcher_iam_role_test.go +++ b/internal/inventory/awsfetcher/fetcher_iam_role_test.go @@ -88,6 +88,7 @@ func TestIAMRoleFetcher_Fetch(t *testing.T) { ID: "arn:aws:iam::0000:role/role-name-1", Name: "role-name-1", }), + inventory.WithCreatedAt(&now), ), inventory.NewAssetEvent( @@ -107,6 +108,7 @@ func TestIAMRoleFetcher_Fetch(t *testing.T) { ID: "arn:aws:iam::0000:role/role-name-2", Name: "role-name-2", }), + inventory.WithCreatedAt(&now), ), } diff --git a/internal/inventory/awsfetcher/fetcher_iam_user.go b/internal/inventory/awsfetcher/fetcher_iam_user.go index 201534d261..3104232537 100644 --- a/internal/inventory/awsfetcher/fetcher_iam_user.go +++ b/internal/inventory/awsfetcher/fetcher_iam_user.go @@ -92,6 +92,7 @@ func (i *iamUserFetcher) Fetch(ctx context.Context, assetChannel chan<- inventor ID: user.GetResourceArn(), Name: user.GetResourceName(), }), + inventory.WithCreatedAt(user.CreateDate), ) } } diff --git a/internal/inventory/awsfetcher/fetcher_rds.go b/internal/inventory/awsfetcher/fetcher_rds.go index 52840cac38..c436dd02ba 100644 --- a/internal/inventory/awsfetcher/fetcher_rds.go +++ b/internal/inventory/awsfetcher/fetcher_rds.go @@ -70,6 +70,16 @@ func (s *rdsFetcher) Fetch(ctx context.Context, assetChannel chan<- inventory.As }) for _, item := range rdsInstances { + attrs := map[string]any{ + "PubliclyAccessible": item.PubliclyAccessible, + } + if item.Engine != "" { + attrs["Engine"] = item.Engine + } + if item.EngineVersion != "" { + attrs["EngineVersion"] = item.EngineVersion + } + assetChannel <- inventory.NewAssetEvent( inventory.AssetClassificationAwsRds, item.GetResourceArn(), @@ -83,6 +93,8 @@ func (s *rdsFetcher) Fetch(ctx context.Context, assetChannel chan<- inventory.As AccountName: s.AccountName, ServiceName: "AWS RDS", }), + inventory.WithEntityAttributes(attrs), + inventory.WithCreatedAt(item.CreatedAt), ) } } diff --git a/internal/inventory/awsfetcher/fetcher_rds_test.go b/internal/inventory/awsfetcher/fetcher_rds_test.go index 06f3158521..0ad735df19 100644 --- a/internal/inventory/awsfetcher/fetcher_rds_test.go +++ b/internal/inventory/awsfetcher/fetcher_rds_test.go @@ -19,6 +19,7 @@ package awsfetcher import ( "testing" + "time" "github.com/stretchr/testify/mock" @@ -33,12 +34,17 @@ import ( ) func TestRDSInstanceFetcher_Fetch(t *testing.T) { + createdAt := time.Date(2024, 3, 10, 8, 0, 0, 0, time.UTC) + instance1 := rds.DBInstance{ Identifier: "db1", Arn: "arn:aws:rds:eu-west-1:123:db:db1", StorageEncrypted: true, AutoMinorVersionUpgrade: true, PubliclyAccessible: false, + Engine: "postgres", + EngineVersion: "15.4", + CreatedAt: &createdAt, Subnets: []rds.Subnet{ { ID: "subnet-aabbccdd", @@ -60,6 +66,8 @@ func TestRDSInstanceFetcher_Fetch(t *testing.T) { StorageEncrypted: true, AutoMinorVersionUpgrade: true, PubliclyAccessible: true, + Engine: "mysql", + EngineVersion: "8.0.35", Subnets: []rds.Subnet{ { ID: "subnet-aabbccdd", @@ -95,6 +103,12 @@ func TestRDSInstanceFetcher_Fetch(t *testing.T) { AccountName: "alias", ServiceName: "AWS RDS", }), + inventory.WithEntityAttributes(map[string]any{ + "PubliclyAccessible": false, + "Engine": "postgres", + "EngineVersion": "15.4", + }), + inventory.WithCreatedAt(&createdAt), ), inventory.NewAssetEvent( inventory.AssetClassificationAwsRds, @@ -108,6 +122,11 @@ func TestRDSInstanceFetcher_Fetch(t *testing.T) { AccountName: "alias", ServiceName: "AWS RDS", }), + inventory.WithEntityAttributes(map[string]any{ + "PubliclyAccessible": true, + "Engine": "mysql", + "EngineVersion": "8.0.35", + }), ), } diff --git a/internal/inventory/awsfetcher/fetcher_s3_bucket.go b/internal/inventory/awsfetcher/fetcher_s3_bucket.go index c28b71be13..2a6fffaf60 100644 --- a/internal/inventory/awsfetcher/fetcher_s3_bucket.go +++ b/internal/inventory/awsfetcher/fetcher_s3_bucket.go @@ -83,6 +83,7 @@ func (s *s3BucketFetcher) Fetch(ctx context.Context, assetChannel chan<- invento AccountName: s.AccountName, ServiceName: "AWS S3", }), + inventory.WithCreatedAt(bucket.CreationDate), ) } } diff --git a/internal/inventory/azurefetcher/fetcher_activedirectory.go b/internal/inventory/azurefetcher/fetcher_activedirectory.go index dd2958c020..c04103e03c 100644 --- a/internal/inventory/azurefetcher/fetcher_activedirectory.go +++ b/internal/inventory/azurefetcher/fetcher_activedirectory.go @@ -144,6 +144,7 @@ func (f *activedirectoryFetcher) fetchGroups(ctx context.Context, assetChan chan ID: pointers.Deref(item.GetId()), Name: pointers.Deref(item.GetDisplayName()), }), + inventory.WithCreatedAt(item.GetCreatedDateTime()), ) } } @@ -174,6 +175,7 @@ func (f *activedirectoryFetcher) fetchUsers(ctx context.Context, assetChan chan< ID: pointers.Deref(item.GetId()), Name: pointers.Deref(item.GetDisplayName()), }), + inventory.WithCreatedAt(item.GetCreatedDateTime()), ) } } diff --git a/internal/inventory/azurefetcher/fetcher_resource_graph.go b/internal/inventory/azurefetcher/fetcher_resource_graph.go index 3fe2614468..b42cb33ecd 100644 --- a/internal/inventory/azurefetcher/fetcher_resource_graph.go +++ b/internal/inventory/azurefetcher/fetcher_resource_graph.go @@ -19,6 +19,7 @@ package azurefetcher import ( "context" + "time" "github.com/go-viper/mapstructure/v2" @@ -97,6 +98,7 @@ func (f *resourceGraphFetcher) fetch(ctx context.Context, resourceName, serviceN ServiceName: serviceName, }), inventory.WithLabelsFromAny(item.Tags), + inventory.WithCreatedAt(extractAzureCreatedAt(item.Properties)), ) if resourceType == azurelib.VirtualMachineAssetType { @@ -138,3 +140,28 @@ func tryUnpackingVMProperties(m map[string]any) *vmProperties { } return o } + +// extractAzureCreatedAt tries common ARM property keys that carry a resource creation +// timestamp. Values may arrive as time.Time (from typed SDK providers) or as RFC3339 strings +// (from the Resource Graph JSON response). Returns nil when no creation time is found. +func extractAzureCreatedAt(properties map[string]any) *time.Time { + for _, key := range []string{"createdTime", "timeCreated", "creationTime", "creationDate"} { + v, ok := properties[key] + if !ok { + continue + } + switch t := v.(type) { + case time.Time: + return &t + case *time.Time: + return t + case string: + if parsed, err := time.Parse(time.RFC3339, t); err == nil { + return &parsed + } + default: + _ = t + } + } + return nil +} diff --git a/internal/inventory/gcpfetcher/fetcher_assets.go b/internal/inventory/gcpfetcher/fetcher_assets.go index c3f2e88ab1..698741d2e1 100644 --- a/internal/inventory/gcpfetcher/fetcher_assets.go +++ b/internal/inventory/gcpfetcher/fetcher_assets.go @@ -20,6 +20,7 @@ package gcpfetcher import ( "context" "strings" + "time" "github.com/go-viper/mapstructure/v2" "github.com/samber/lo" @@ -107,9 +108,15 @@ func getAssetEvent(classification inventory.AssetClassification, item *gcpinvent }), } - // Asset type specific enrichers + // Asset type specific enrichers and common resource-level fields if hasResourceData(item) { fields := item.GetResource().GetData().GetFields() + // Best-effort creation timestamp: try the common field names used across GCP resource types. + // Compute resources use "creationTimestamp"; GKE/Functions/CloudRun use "createTime"; + // Storage buckets use "timeCreated". + enrichers = append(enrichers, inventory.WithCreatedAt( + getFirstTimeValue(fields, "createTime", "creationTimestamp", "timeCreated"), + )) if enricher, ok := assetEnrichers[item.AssetType]; ok { enrichers = append(enrichers, enricher(item, fields)...) } @@ -371,3 +378,18 @@ func getStringValue(key string, f map[string]*structpb.Value) string { } return "" } + +// getFirstTimeValue tries each key in turn and returns a parsed *time.Time from the first +// key whose value is a non-empty RFC3339 string. Returns nil when none are found. +func getFirstTimeValue(f map[string]*structpb.Value, keys ...string) *time.Time { + for _, key := range keys { + raw := getStringValue(key, f) + if raw == "" { + continue + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return &t + } + } + return nil +} diff --git a/internal/resources/providers/awslib/elb/elb.go b/internal/resources/providers/awslib/elb/elb.go index 53c63e73a6..1ab79536e1 100644 --- a/internal/resources/providers/awslib/elb/elb.go +++ b/internal/resources/providers/awslib/elb/elb.go @@ -30,6 +30,7 @@ import ( type Client interface { elb.DescribeLoadBalancersAPIClient + DescribeTags(ctx context.Context, params *elb.DescribeTagsInput, optFns ...func(*elb.Options)) (*elb.DescribeTagsOutput, error) } type LoadBalancerDescriber interface { diff --git a/internal/resources/providers/awslib/elb/elb_mock.go b/internal/resources/providers/awslib/elb/elb_mock.go index 0692e8e93d..d2da91ebdf 100644 --- a/internal/resources/providers/awslib/elb/elb_mock.go +++ b/internal/resources/providers/awslib/elb/elb_mock.go @@ -141,6 +141,89 @@ func (_c *MockClient_DescribeLoadBalancers_Call) RunAndReturn(run func(context1 return _c } +// DescribeTags provides a mock function for the type MockClient +func (_mock *MockClient) DescribeTags(ctx context.Context, params *elasticloadbalancing.DescribeTagsInput, optFns ...func(*elasticloadbalancing.Options)) (*elasticloadbalancing.DescribeTagsOutput, error) { + var tmpRet mock.Arguments + if len(optFns) > 0 { + tmpRet = _mock.Called(ctx, params, optFns) + } else { + tmpRet = _mock.Called(ctx, params) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for DescribeTags") + } + + var r0 *elasticloadbalancing.DescribeTagsOutput + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *elasticloadbalancing.DescribeTagsInput, ...func(*elasticloadbalancing.Options)) (*elasticloadbalancing.DescribeTagsOutput, error)); ok { + return returnFunc(ctx, params, optFns...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *elasticloadbalancing.DescribeTagsInput, ...func(*elasticloadbalancing.Options)) *elasticloadbalancing.DescribeTagsOutput); ok { + r0 = returnFunc(ctx, params, optFns...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*elasticloadbalancing.DescribeTagsOutput) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *elasticloadbalancing.DescribeTagsInput, ...func(*elasticloadbalancing.Options)) error); ok { + r1 = returnFunc(ctx, params, optFns...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_DescribeTags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DescribeTags' +type MockClient_DescribeTags_Call struct { + *mock.Call +} + +// DescribeTags is a helper method to define mock.On call +// - ctx context.Context +// - params *elasticloadbalancing.DescribeTagsInput +// - optFns ...func(*elasticloadbalancing.Options) +func (_e *MockClient_Expecter) DescribeTags(ctx interface{}, params interface{}, optFns ...interface{}) *MockClient_DescribeTags_Call { + return &MockClient_DescribeTags_Call{Call: _e.mock.On("DescribeTags", + append([]interface{}{ctx, params}, optFns...)...)} +} + +func (_c *MockClient_DescribeTags_Call) Run(run func(ctx context.Context, params *elasticloadbalancing.DescribeTagsInput, optFns ...func(*elasticloadbalancing.Options))) *MockClient_DescribeTags_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *elasticloadbalancing.DescribeTagsInput + if args[1] != nil { + arg1 = args[1].(*elasticloadbalancing.DescribeTagsInput) + } + var arg2 []func(*elasticloadbalancing.Options) + var variadicArgs []func(*elasticloadbalancing.Options) + if len(args) > 2 { + variadicArgs = args[2].([]func(*elasticloadbalancing.Options)) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *MockClient_DescribeTags_Call) Return(describeTagsOutput *elasticloadbalancing.DescribeTagsOutput, err error) *MockClient_DescribeTags_Call { + _c.Call.Return(describeTagsOutput, err) + return _c +} + +func (_c *MockClient_DescribeTags_Call) RunAndReturn(run func(ctx context.Context, params *elasticloadbalancing.DescribeTagsInput, optFns ...func(*elasticloadbalancing.Options)) (*elasticloadbalancing.DescribeTagsOutput, error)) *MockClient_DescribeTags_Call { + _c.Call.Return(run) + return _c +} + // NewMockLoadBalancerDescriber creates a new instance of MockLoadBalancerDescriber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockLoadBalancerDescriber(t interface { diff --git a/internal/resources/providers/awslib/elb/load_balancer.go b/internal/resources/providers/awslib/elb/load_balancer.go index 4ccedbdad0..6c43aee676 100644 --- a/internal/resources/providers/awslib/elb/load_balancer.go +++ b/internal/resources/providers/awslib/elb/load_balancer.go @@ -19,10 +19,12 @@ package elb import ( "fmt" + "time" "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancing/types" "github.com/elastic/cloudbeat/internal/resources/fetching" + "github.com/elastic/cloudbeat/internal/resources/providers/awslib" "github.com/elastic/cloudbeat/internal/resources/utils/pointers" ) @@ -30,6 +32,7 @@ type ElasticLoadBalancerInfo struct { LoadBalancer types.LoadBalancerDescription `json:"load_balancer"` awsAccount string region string + tags map[string]string } func (v ElasticLoadBalancerInfo) GetResourceArn() string { @@ -51,3 +54,36 @@ func (v ElasticLoadBalancerInfo) GetResourceType() string { func (v ElasticLoadBalancerInfo) GetRegion() string { return v.region } + +func (v ElasticLoadBalancerInfo) GetDNSName() string { + return pointers.Deref(v.LoadBalancer.DNSName) +} + +func (v ElasticLoadBalancerInfo) IsPubliclyAccessible() bool { + return pointers.Deref(v.LoadBalancer.Scheme) == "internet-facing" +} + +func (v ElasticLoadBalancerInfo) GetCreatedAt() *time.Time { + return v.LoadBalancer.CreatedTime +} + +// GetLoadBalancerType reports the load balancer type. Classic load balancers have no +// type field in the SDK, so we report a stable "classic" value. +func (v ElasticLoadBalancerInfo) GetLoadBalancerType() string { + return "classic" +} + +// GetState is not exposed for classic load balancers by the AWS SDK. +func (v ElasticLoadBalancerInfo) GetState() string { + return "" +} + +// GetIPAddresses is not exposed for classic load balancers (they are DNS-only). +func (v ElasticLoadBalancerInfo) GetIPAddresses() []string { + return nil +} + +// GetOwnerTag returns the value of the "Owner" tag (case-insensitive), if present. +func (v ElasticLoadBalancerInfo) GetOwnerTag() string { + return awslib.LookupTag(v.tags, "owner") +} diff --git a/internal/resources/providers/awslib/elb/provider.go b/internal/resources/providers/awslib/elb/provider.go index 1f0c061b4d..9ab72a8a36 100644 --- a/internal/resources/providers/awslib/elb/provider.go +++ b/internal/resources/providers/awslib/elb/provider.go @@ -26,6 +26,7 @@ import ( "github.com/samber/lo" "github.com/elastic/cloudbeat/internal/resources/providers/awslib" + "github.com/elastic/cloudbeat/internal/resources/utils/pointers" ) // DescribeLoadBalancers returns LoadBalancerDescriptions which contain information about the load balancers. @@ -67,12 +68,21 @@ func (p *Provider) DescribeAllLoadBalancers(ctx context.Context) ([]awslib.AwsRe input.Marker = output.NextMarker } + names := make([]string, 0, len(all)) + for _, item := range all { + if n := pointers.Deref(item.LoadBalancerName); n != "" { + names = append(names, n) + } + } + tagsByName := p.describeTags(ctx, c, names) + var result []awslib.AwsResource for _, item := range all { result = append(result, &ElasticLoadBalancerInfo{ LoadBalancer: item, awsAccount: p.awsAccountID, region: region, + tags: tagsByName[pointers.Deref(item.LoadBalancerName)], }) } return result, nil @@ -83,3 +93,31 @@ func (p *Provider) DescribeAllLoadBalancers(ctx context.Context) ([]awslib.AwsRe } return lo.Flatten(elbs), err } + +// describeTags fetches tags for the given classic load balancer names (chunked to the AWS +// 20-name limit) and returns a map of load balancer name to its tag key/value pairs. +func (p *Provider) describeTags(ctx context.Context, c Client, names []string) map[string]map[string]string { + out := map[string]map[string]string{} + for _, chunk := range lo.Chunk(names, 20) { + if len(chunk) == 0 { + continue + } + resp, err := c.DescribeTags(ctx, &elb.DescribeTagsInput{LoadBalancerNames: chunk}) + if err != nil { + p.log.Errorf("Could not fetch tags for classic load balancers: %v", err) + continue + } + for _, td := range resp.TagDescriptions { + name := pointers.Deref(td.LoadBalancerName) + if name == "" { + continue + } + tags := make(map[string]string, len(td.Tags)) + for _, t := range td.Tags { + tags[pointers.Deref(t.Key)] = pointers.Deref(t.Value) + } + out[name] = tags + } + } + return out +} diff --git a/internal/resources/providers/awslib/elb/provider_test.go b/internal/resources/providers/awslib/elb/provider_test.go index 4ef634e455..a85bfb71b8 100644 --- a/internal/resources/providers/awslib/elb/provider_test.go +++ b/internal/resources/providers/awslib/elb/provider_test.go @@ -172,6 +172,17 @@ func TestProvider_DescribeAllLoadBalancers(t *testing.T) { }, }, }, nil) + m.On("DescribeTags", mock.Anything, mock.Anything, mock.Anything). + Return(&elasticloadbalancing.DescribeTagsOutput{ + TagDescriptions: []types.TagDescription{ + { + LoadBalancerName: pointers.Ref("my-elb-v1"), + Tags: []types.Tag{ + {Key: pointers.Ref("Owner"), Value: pointers.Ref("team-infra")}, + }, + }, + }, + }, nil) return m }, regions: onlyDefaultRegion, @@ -199,6 +210,11 @@ func TestProvider_DescribeAllLoadBalancers(t *testing.T) { require.NoError(t, err) assert.Len(t, got, tt.expectedResults) + if len(got) > 0 { + lb, ok := got[0].(*ElasticLoadBalancerInfo) + require.True(t, ok) + assert.Equal(t, "team-infra", lb.GetOwnerTag()) + } }) } } diff --git a/internal/resources/providers/awslib/elb_v2/elb_v2.go b/internal/resources/providers/awslib/elb_v2/elb_v2.go index dcb76a4d75..63e7c212b2 100644 --- a/internal/resources/providers/awslib/elb_v2/elb_v2.go +++ b/internal/resources/providers/awslib/elb_v2/elb_v2.go @@ -30,6 +30,7 @@ import ( type Client interface { elb.DescribeLoadBalancersAPIClient elb.DescribeListenersAPIClient + DescribeTags(ctx context.Context, params *elb.DescribeTagsInput, optFns ...func(*elb.Options)) (*elb.DescribeTagsOutput, error) } type LoadBalancerDescriber interface { diff --git a/internal/resources/providers/awslib/elb_v2/elb_v2_mock.go b/internal/resources/providers/awslib/elb_v2/elb_v2_mock.go index 361f66fdd3..190b8ae568 100644 --- a/internal/resources/providers/awslib/elb_v2/elb_v2_mock.go +++ b/internal/resources/providers/awslib/elb_v2/elb_v2_mock.go @@ -223,6 +223,89 @@ func (_c *MockClient_DescribeLoadBalancers_Call) RunAndReturn(run func(context1 return _c } +// DescribeTags provides a mock function for the type MockClient +func (_mock *MockClient) DescribeTags(ctx context.Context, params *elasticloadbalancingv2.DescribeTagsInput, optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DescribeTagsOutput, error) { + var tmpRet mock.Arguments + if len(optFns) > 0 { + tmpRet = _mock.Called(ctx, params, optFns) + } else { + tmpRet = _mock.Called(ctx, params) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for DescribeTags") + } + + var r0 *elasticloadbalancingv2.DescribeTagsOutput + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *elasticloadbalancingv2.DescribeTagsInput, ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DescribeTagsOutput, error)); ok { + return returnFunc(ctx, params, optFns...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *elasticloadbalancingv2.DescribeTagsInput, ...func(*elasticloadbalancingv2.Options)) *elasticloadbalancingv2.DescribeTagsOutput); ok { + r0 = returnFunc(ctx, params, optFns...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*elasticloadbalancingv2.DescribeTagsOutput) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *elasticloadbalancingv2.DescribeTagsInput, ...func(*elasticloadbalancingv2.Options)) error); ok { + r1 = returnFunc(ctx, params, optFns...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_DescribeTags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DescribeTags' +type MockClient_DescribeTags_Call struct { + *mock.Call +} + +// DescribeTags is a helper method to define mock.On call +// - ctx context.Context +// - params *elasticloadbalancingv2.DescribeTagsInput +// - optFns ...func(*elasticloadbalancingv2.Options) +func (_e *MockClient_Expecter) DescribeTags(ctx interface{}, params interface{}, optFns ...interface{}) *MockClient_DescribeTags_Call { + return &MockClient_DescribeTags_Call{Call: _e.mock.On("DescribeTags", + append([]interface{}{ctx, params}, optFns...)...)} +} + +func (_c *MockClient_DescribeTags_Call) Run(run func(ctx context.Context, params *elasticloadbalancingv2.DescribeTagsInput, optFns ...func(*elasticloadbalancingv2.Options))) *MockClient_DescribeTags_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *elasticloadbalancingv2.DescribeTagsInput + if args[1] != nil { + arg1 = args[1].(*elasticloadbalancingv2.DescribeTagsInput) + } + var arg2 []func(*elasticloadbalancingv2.Options) + var variadicArgs []func(*elasticloadbalancingv2.Options) + if len(args) > 2 { + variadicArgs = args[2].([]func(*elasticloadbalancingv2.Options)) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *MockClient_DescribeTags_Call) Return(describeTagsOutput *elasticloadbalancingv2.DescribeTagsOutput, err error) *MockClient_DescribeTags_Call { + _c.Call.Return(describeTagsOutput, err) + return _c +} + +func (_c *MockClient_DescribeTags_Call) RunAndReturn(run func(ctx context.Context, params *elasticloadbalancingv2.DescribeTagsInput, optFns ...func(*elasticloadbalancingv2.Options)) (*elasticloadbalancingv2.DescribeTagsOutput, error)) *MockClient_DescribeTags_Call { + _c.Call.Return(run) + return _c +} + // NewMockLoadBalancerDescriber creates a new instance of MockLoadBalancerDescriber. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewMockLoadBalancerDescriber(t interface { diff --git a/internal/resources/providers/awslib/elb_v2/load_balancer_v2.go b/internal/resources/providers/awslib/elb_v2/load_balancer_v2.go index 371122ef76..266f9b375b 100644 --- a/internal/resources/providers/awslib/elb_v2/load_balancer_v2.go +++ b/internal/resources/providers/awslib/elb_v2/load_balancer_v2.go @@ -18,9 +18,12 @@ package elb_v2 import ( + "time" + "github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2/types" "github.com/elastic/cloudbeat/internal/resources/fetching" + "github.com/elastic/cloudbeat/internal/resources/providers/awslib" "github.com/elastic/cloudbeat/internal/resources/utils/pointers" ) @@ -28,6 +31,7 @@ type ElasticLoadBalancerInfo struct { LoadBalancer types.LoadBalancer `json:"load_balancer"` Listeners []types.Listener `json:"listeners"` region string + tags map[string]string } func (v ElasticLoadBalancerInfo) GetResourceArn() string { @@ -45,3 +49,47 @@ func (v ElasticLoadBalancerInfo) GetResourceType() string { func (v ElasticLoadBalancerInfo) GetRegion() string { return v.region } + +func (v ElasticLoadBalancerInfo) GetDNSName() string { + return pointers.Deref(v.LoadBalancer.DNSName) +} + +func (v ElasticLoadBalancerInfo) IsPubliclyAccessible() bool { + return v.LoadBalancer.Scheme == types.LoadBalancerSchemeEnumInternetFacing +} + +func (v ElasticLoadBalancerInfo) GetCreatedAt() *time.Time { + return v.LoadBalancer.CreatedTime +} + +// GetLoadBalancerType reports the load balancer type (application, network, gateway). +func (v ElasticLoadBalancerInfo) GetLoadBalancerType() string { + return string(v.LoadBalancer.Type) +} + +// GetState reports the load balancer state code (e.g. active, provisioning). +func (v ElasticLoadBalancerInfo) GetState() string { + if v.LoadBalancer.State == nil { + return "" + } + return string(v.LoadBalancer.State.Code) +} + +// GetIPAddresses returns the static IP addresses of the load balancer. Only Network Load +// Balancers expose static IPs (via per-AZ addresses); ALB/Gateway return nil. +func (v ElasticLoadBalancerInfo) GetIPAddresses() []string { + var ips []string + for _, az := range v.LoadBalancer.AvailabilityZones { + for _, addr := range az.LoadBalancerAddresses { + if ip := pointers.Deref(addr.IpAddress); ip != "" { + ips = append(ips, ip) + } + } + } + return ips +} + +// GetOwnerTag returns the value of the "Owner" tag (case-insensitive), if present. +func (v ElasticLoadBalancerInfo) GetOwnerTag() string { + return awslib.LookupTag(v.tags, "owner") +} diff --git a/internal/resources/providers/awslib/elb_v2/provider_v2.go b/internal/resources/providers/awslib/elb_v2/provider_v2.go index d7e8699df2..6da298e865 100644 --- a/internal/resources/providers/awslib/elb_v2/provider_v2.go +++ b/internal/resources/providers/awslib/elb_v2/provider_v2.go @@ -46,7 +46,8 @@ func (p *Provider) DescribeLoadBalancers(ctx context.Context) ([]awslib.AwsResou input.Marker = output.NextMarker } - var result []awslib.AwsResource + lbs := make([]*ElasticLoadBalancerInfo, 0, len(all)) + arns := make([]string, 0, len(all)) for _, item := range all { loadBalancer := &ElasticLoadBalancerInfo{ LoadBalancer: item, @@ -58,7 +59,18 @@ func (p *Provider) DescribeLoadBalancers(ctx context.Context) ([]awslib.AwsResou } else { loadBalancer.Listeners = listeners } - result = append(result, loadBalancer) + lbs = append(lbs, loadBalancer) + if arn := loadBalancer.GetResourceArn(); arn != "" { + arns = append(arns, arn) + } + } + + tagsByArn := p.describeTags(ctx, c, arns) + + result := make([]awslib.AwsResource, 0, len(lbs)) + for _, lb := range lbs { + lb.tags = tagsByArn[lb.GetResourceArn()] + result = append(result, lb) } return result, nil }) @@ -69,6 +81,34 @@ func (p *Provider) DescribeLoadBalancers(ctx context.Context) ([]awslib.AwsResou return result, err } +// describeTags fetches tags for the given load balancer ARNs (chunked to the AWS 20-ARN +// limit) and returns a map of load balancer ARN to its tag key/value pairs. +func (p *Provider) describeTags(ctx context.Context, c Client, arns []string) map[string]map[string]string { + out := map[string]map[string]string{} + for _, chunk := range lo.Chunk(arns, 20) { + if len(chunk) == 0 { + continue + } + resp, err := c.DescribeTags(ctx, &elbv2.DescribeTagsInput{ResourceArns: chunk}) + if err != nil { + p.log.Errorf("Could not fetch tags for load balancers: %v", err) + continue + } + for _, td := range resp.TagDescriptions { + arn := pointers.Deref(td.ResourceArn) + if arn == "" { + continue + } + tags := make(map[string]string, len(td.Tags)) + for _, t := range td.Tags { + tags[pointers.Deref(t.Key)] = pointers.Deref(t.Value) + } + out[arn] = tags + } + } + return out +} + // describeListeners queries and returns all Listeners filtered by ELB ARN and region. // Used by DescribeLoadBalancers to find Listeners connected to a specific Elastic Load // Balancer (v2). diff --git a/internal/resources/providers/awslib/elb_v2/provider_v2_test.go b/internal/resources/providers/awslib/elb_v2/provider_v2_test.go index 8254aaf9e0..84ca305bd5 100644 --- a/internal/resources/providers/awslib/elb_v2/provider_v2_test.go +++ b/internal/resources/providers/awslib/elb_v2/provider_v2_test.go @@ -69,6 +69,7 @@ func TestProvider_DescribeLoadBalancers(t *testing.T) { }, }, }, nil) + m.On("DescribeTags", mock.Anything, mock.Anything, mock.Anything).Return(&elb.DescribeTagsOutput{}, nil) return m }, regions: onlyDefaultRegion, @@ -85,7 +86,13 @@ func TestProvider_DescribeLoadBalancers(t *testing.T) { Return(&elb.DescribeLoadBalancersOutput{ LoadBalancers: []types.LoadBalancer{ { - AvailabilityZones: []types.AvailabilityZone{}, + AvailabilityZones: []types.AvailabilityZone{ + { + LoadBalancerAddresses: []types.LoadBalancerAddress{ + {IpAddress: pointers.Ref("203.0.113.10")}, + }, + }, + }, CanonicalHostedZoneId: pointers.Ref("HZ-ID"), CreatedTime: pointers.Ref(time.Now()), CustomerOwnedIpv4Pool: pointers.Ref("10.0.0.0/24"), @@ -95,11 +102,23 @@ func TestProvider_DescribeLoadBalancers(t *testing.T) { LoadBalancerName: pointers.Ref("my-elb-v2"), Scheme: types.LoadBalancerSchemeEnumInternal, SecurityGroups: []string{}, - Type: types.LoadBalancerTypeEnumApplication, + State: &types.LoadBalancerState{Code: types.LoadBalancerStateEnumActive}, + Type: types.LoadBalancerTypeEnumNetwork, VpcId: pointers.Ref(""), }, }, }, nil) + m.On("DescribeTags", mock.Anything, mock.Anything, mock.Anything). + Return(&elb.DescribeTagsOutput{ + TagDescriptions: []types.TagDescription{ + { + ResourceArn: pointers.Ref("arn:aws:elasticloadbalancing:::loadbalancer/my-elb-v2"), + Tags: []types.Tag{ + {Key: pointers.Ref("Owner"), Value: pointers.Ref("team-infra")}, + }, + }, + }, + }, nil) return m }, regions: onlyDefaultRegion, @@ -136,6 +155,7 @@ func TestProvider_DescribeLoadBalancers(t *testing.T) { }, }, }, nil) + m.On("DescribeTags", mock.Anything, mock.Anything, mock.Anything).Return(&elb.DescribeTagsOutput{}, nil) return m }, regions: onlyDefaultRegion, @@ -160,6 +180,14 @@ func TestProvider_DescribeLoadBalancers(t *testing.T) { require.NoError(t, err) assert.Len(t, got, tt.expectedResults) + if tt.name == "with resources" { + lb, ok := got[0].(*ElasticLoadBalancerInfo) + require.True(t, ok) + assert.Equal(t, "team-infra", lb.GetOwnerTag()) + assert.Equal(t, "network", lb.GetLoadBalancerType()) + assert.Equal(t, "active", lb.GetState()) + assert.Equal(t, []string{"203.0.113.10"}, lb.GetIPAddresses()) + } }) } } diff --git a/internal/resources/providers/awslib/iam/iam.go b/internal/resources/providers/awslib/iam/iam.go index b4a5fc9420..3b6317cb9b 100644 --- a/internal/resources/providers/awslib/iam/iam.go +++ b/internal/resources/providers/awslib/iam/iam.go @@ -19,6 +19,7 @@ package iam import ( "context" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/accessanalyzer" @@ -59,6 +60,7 @@ type Client interface { GetPolicyVersion(ctx context.Context, params *iamsdk.GetPolicyVersionInput, optFns ...func(*iamsdk.Options)) (*iamsdk.GetPolicyVersionOutput, error) ListEntitiesForPolicy(ctx context.Context, params *iamsdk.ListEntitiesForPolicyInput, optFns ...func(*iamsdk.Options)) (*iamsdk.ListEntitiesForPolicyOutput, error) ListServerCertificates(ctx context.Context, params *iamsdk.ListServerCertificatesInput, optFns ...func(*iamsdk.Options)) (*iamsdk.ListServerCertificatesOutput, error) + GetInstanceProfile(ctx context.Context, params *iamsdk.GetInstanceProfileInput, optFns ...func(*iamsdk.Options)) (*iamsdk.GetInstanceProfileOutput, error) } type AccessAnalyzerClient interface { @@ -86,6 +88,7 @@ type User struct { LastAccess string `json:"last_access,omitempty"` Arn string `json:"arn,omitempty"` PasswordLastChanged string `json:"password_last_changed,omitempty"` + CreateDate *time.Time `json:"create_date,omitempty"` MfaActive bool `json:"mfa_active"` PasswordEnabled bool `json:"password_enabled"` UserId string `json:"user_id"` diff --git a/internal/resources/providers/awslib/iam/iam_mock.go b/internal/resources/providers/awslib/iam/iam_mock.go index c59a830785..4dce8f6938 100644 --- a/internal/resources/providers/awslib/iam/iam_mock.go +++ b/internal/resources/providers/awslib/iam/iam_mock.go @@ -795,6 +795,89 @@ func (_c *MockClient_GetCredentialReport_Call) RunAndReturn(run func(ctx context return _c } +// GetInstanceProfile provides a mock function for the type MockClient +func (_mock *MockClient) GetInstanceProfile(ctx context.Context, params *iam.GetInstanceProfileInput, optFns ...func(*iam.Options)) (*iam.GetInstanceProfileOutput, error) { + var tmpRet mock.Arguments + if len(optFns) > 0 { + tmpRet = _mock.Called(ctx, params, optFns) + } else { + tmpRet = _mock.Called(ctx, params) + } + ret := tmpRet + + if len(ret) == 0 { + panic("no return value specified for GetInstanceProfile") + } + + var r0 *iam.GetInstanceProfileOutput + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *iam.GetInstanceProfileInput, ...func(*iam.Options)) (*iam.GetInstanceProfileOutput, error)); ok { + return returnFunc(ctx, params, optFns...) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, *iam.GetInstanceProfileInput, ...func(*iam.Options)) *iam.GetInstanceProfileOutput); ok { + r0 = returnFunc(ctx, params, optFns...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iam.GetInstanceProfileOutput) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, *iam.GetInstanceProfileInput, ...func(*iam.Options)) error); ok { + r1 = returnFunc(ctx, params, optFns...) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_GetInstanceProfile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstanceProfile' +type MockClient_GetInstanceProfile_Call struct { + *mock.Call +} + +// GetInstanceProfile is a helper method to define mock.On call +// - ctx context.Context +// - params *iam.GetInstanceProfileInput +// - optFns ...func(*iam.Options) +func (_e *MockClient_Expecter) GetInstanceProfile(ctx interface{}, params interface{}, optFns ...interface{}) *MockClient_GetInstanceProfile_Call { + return &MockClient_GetInstanceProfile_Call{Call: _e.mock.On("GetInstanceProfile", + append([]interface{}{ctx, params}, optFns...)...)} +} + +func (_c *MockClient_GetInstanceProfile_Call) Run(run func(ctx context.Context, params *iam.GetInstanceProfileInput, optFns ...func(*iam.Options))) *MockClient_GetInstanceProfile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *iam.GetInstanceProfileInput + if args[1] != nil { + arg1 = args[1].(*iam.GetInstanceProfileInput) + } + var arg2 []func(*iam.Options) + var variadicArgs []func(*iam.Options) + if len(args) > 2 { + variadicArgs = args[2].([]func(*iam.Options)) + } + arg2 = variadicArgs + run( + arg0, + arg1, + arg2..., + ) + }) + return _c +} + +func (_c *MockClient_GetInstanceProfile_Call) Return(getInstanceProfileOutput *iam.GetInstanceProfileOutput, err error) *MockClient_GetInstanceProfile_Call { + _c.Call.Return(getInstanceProfileOutput, err) + return _c +} + +func (_c *MockClient_GetInstanceProfile_Call) RunAndReturn(run func(ctx context.Context, params *iam.GetInstanceProfileInput, optFns ...func(*iam.Options)) (*iam.GetInstanceProfileOutput, error)) *MockClient_GetInstanceProfile_Call { + _c.Call.Return(run) + return _c +} + // GetPolicy provides a mock function for the type MockClient func (_mock *MockClient) GetPolicy(ctx context.Context, params *iam.GetPolicyInput, optFns ...func(*iam.Options)) (*iam.GetPolicyOutput, error) { var tmpRet mock.Arguments diff --git a/internal/resources/providers/awslib/iam/instance_profile.go b/internal/resources/providers/awslib/iam/instance_profile.go new file mode 100644 index 0000000000..0ebb25de05 --- /dev/null +++ b/internal/resources/providers/awslib/iam/instance_profile.go @@ -0,0 +1,50 @@ +// 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 ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/iam" + "github.com/aws/aws-sdk-go-v2/service/iam/types" +) + +// InstanceProfileGetter resolves an IAM instance profile by name. +type InstanceProfileGetter interface { + GetInstanceProfile(ctx context.Context, instanceProfileName string) (*types.InstanceProfile, error) +} + +// GetInstanceProfile fetches the IAM instance profile with the given name and +// returns the profile object, which includes the list of attached roles. +func (p Provider) GetInstanceProfile(ctx context.Context, instanceProfileName string) (*types.InstanceProfile, error) { + input := &iam.GetInstanceProfileInput{ + InstanceProfileName: &instanceProfileName, + } + + response, err := p.client.GetInstanceProfile(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to get instance profile %s - %w", instanceProfileName, err) + } + + if response.InstanceProfile == nil { + return nil, fmt.Errorf("GetInstanceProfile returned nil for %s", instanceProfileName) + } + + return response.InstanceProfile, nil +} diff --git a/internal/resources/providers/awslib/iam/instance_profile_mock.go b/internal/resources/providers/awslib/iam/instance_profile_mock.go new file mode 100644 index 0000000000..fa8052d79d --- /dev/null +++ b/internal/resources/providers/awslib/iam/instance_profile_mock.go @@ -0,0 +1,125 @@ +// 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. +//go:build !release + +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package iam + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/iam/types" + mock "github.com/stretchr/testify/mock" +) + +// NewMockInstanceProfileGetter creates a new instance of MockInstanceProfileGetter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockInstanceProfileGetter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockInstanceProfileGetter { + mock := &MockInstanceProfileGetter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockInstanceProfileGetter is an autogenerated mock type for the InstanceProfileGetter type +type MockInstanceProfileGetter struct { + mock.Mock +} + +type MockInstanceProfileGetter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockInstanceProfileGetter) EXPECT() *MockInstanceProfileGetter_Expecter { + return &MockInstanceProfileGetter_Expecter{mock: &_m.Mock} +} + +// GetInstanceProfile provides a mock function for the type MockInstanceProfileGetter +func (_mock *MockInstanceProfileGetter) GetInstanceProfile(ctx context.Context, instanceProfileName string) (*types.InstanceProfile, error) { + ret := _mock.Called(ctx, instanceProfileName) + + if len(ret) == 0 { + panic("no return value specified for GetInstanceProfile") + } + + var r0 *types.InstanceProfile + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*types.InstanceProfile, error)); ok { + return returnFunc(ctx, instanceProfileName) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *types.InstanceProfile); ok { + r0 = returnFunc(ctx, instanceProfileName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.InstanceProfile) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, instanceProfileName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockInstanceProfileGetter_GetInstanceProfile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetInstanceProfile' +type MockInstanceProfileGetter_GetInstanceProfile_Call struct { + *mock.Call +} + +// GetInstanceProfile is a helper method to define mock.On call +// - ctx context.Context +// - instanceProfileName string +func (_e *MockInstanceProfileGetter_Expecter) GetInstanceProfile(ctx interface{}, instanceProfileName interface{}) *MockInstanceProfileGetter_GetInstanceProfile_Call { + return &MockInstanceProfileGetter_GetInstanceProfile_Call{Call: _e.mock.On("GetInstanceProfile", ctx, instanceProfileName)} +} + +func (_c *MockInstanceProfileGetter_GetInstanceProfile_Call) Run(run func(ctx context.Context, instanceProfileName string)) *MockInstanceProfileGetter_GetInstanceProfile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockInstanceProfileGetter_GetInstanceProfile_Call) Return(instanceProfile *types.InstanceProfile, err error) *MockInstanceProfileGetter_GetInstanceProfile_Call { + _c.Call.Return(instanceProfile, err) + return _c +} + +func (_c *MockInstanceProfileGetter_GetInstanceProfile_Call) RunAndReturn(run func(ctx context.Context, instanceProfileName string) (*types.InstanceProfile, error)) *MockInstanceProfileGetter_GetInstanceProfile_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/resources/providers/awslib/iam/user.go b/internal/resources/providers/awslib/iam/user.go index 3305bedfd8..9aeff1f4bb 100644 --- a/internal/resources/providers/awslib/iam/user.go +++ b/internal/resources/providers/awslib/iam/user.go @@ -108,6 +108,7 @@ func (p Provider) GetUsers(ctx context.Context) ([]awslib.AwsResource, error) { LastAccess: userAccount.PasswordLastUsed, Arn: arn, PasswordLastChanged: userAccount.PasswordLastChanged, + CreateDate: apiUser.CreateDate, PasswordEnabled: pwdEnabled, MfaActive: userAccount.MfaActive, UserId: pointers.Deref(apiUser.UserId), diff --git a/internal/resources/providers/awslib/rds/provider.go b/internal/resources/providers/awslib/rds/provider.go index bffbb5d88b..6975097279 100644 --- a/internal/resources/providers/awslib/rds/provider.go +++ b/internal/resources/providers/awslib/rds/provider.go @@ -72,6 +72,9 @@ func (p Provider) DescribeDBInstances(ctx context.Context) ([]awslib.AwsResource StorageEncrypted: aws.ToBool(dbInstance.StorageEncrypted), AutoMinorVersionUpgrade: aws.ToBool(dbInstance.AutoMinorVersionUpgrade), PubliclyAccessible: aws.ToBool(dbInstance.PubliclyAccessible), + Engine: aws.ToString(dbInstance.Engine), + EngineVersion: aws.ToString(dbInstance.EngineVersion), + CreatedAt: dbInstance.InstanceCreateTime, Subnets: subnets, region: region, }) diff --git a/internal/resources/providers/awslib/rds/rds.go b/internal/resources/providers/awslib/rds/rds.go index fa830ecdf3..978e61fdd9 100644 --- a/internal/resources/providers/awslib/rds/rds.go +++ b/internal/resources/providers/awslib/rds/rds.go @@ -19,6 +19,7 @@ package rds import ( "context" + "time" "github.com/aws/aws-sdk-go-v2/service/rds" @@ -28,12 +29,15 @@ import ( ) type DBInstance struct { - Identifier string `json:"identifier"` - Arn string `json:"arn"` - StorageEncrypted bool `json:"storage_encrypted"` - AutoMinorVersionUpgrade bool `json:"auto_minor_version_upgrade"` - PubliclyAccessible bool `json:"publicly_accessible"` - Subnets []Subnet `json:"subnets"` + Identifier string `json:"identifier"` + Arn string `json:"arn"` + StorageEncrypted bool `json:"storage_encrypted"` + AutoMinorVersionUpgrade bool `json:"auto_minor_version_upgrade"` + PubliclyAccessible bool `json:"publicly_accessible"` + Engine string `json:"engine,omitempty"` + EngineVersion string `json:"engine_version,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Subnets []Subnet `json:"subnets"` region string } diff --git a/internal/resources/providers/awslib/role_chaining_mock.go b/internal/resources/providers/awslib/role_chaining_mock.go new file mode 100644 index 0000000000..2fec300dd9 --- /dev/null +++ b/internal/resources/providers/awslib/role_chaining_mock.go @@ -0,0 +1,109 @@ +// 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. +//go:build !release + +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package awslib + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" + mock "github.com/stretchr/testify/mock" +) + +// NewMockAWSRoleChainingStep creates a new instance of MockAWSRoleChainingStep. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockAWSRoleChainingStep(t interface { + mock.TestingT + Cleanup(func()) +}) *MockAWSRoleChainingStep { + mock := &MockAWSRoleChainingStep{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockAWSRoleChainingStep is an autogenerated mock type for the AWSRoleChainingStep type +type MockAWSRoleChainingStep struct { + mock.Mock +} + +type MockAWSRoleChainingStep_Expecter struct { + mock *mock.Mock +} + +func (_m *MockAWSRoleChainingStep) EXPECT() *MockAWSRoleChainingStep_Expecter { + return &MockAWSRoleChainingStep_Expecter{mock: &_m.Mock} +} + +// BuildCredentialsCache provides a mock function for the type MockAWSRoleChainingStep +func (_mock *MockAWSRoleChainingStep) BuildCredentialsCache(client *sts.Client) *aws.CredentialsCache { + ret := _mock.Called(client) + + if len(ret) == 0 { + panic("no return value specified for BuildCredentialsCache") + } + + var r0 *aws.CredentialsCache + if returnFunc, ok := ret.Get(0).(func(*sts.Client) *aws.CredentialsCache); ok { + r0 = returnFunc(client) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*aws.CredentialsCache) + } + } + return r0 +} + +// MockAWSRoleChainingStep_BuildCredentialsCache_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'BuildCredentialsCache' +type MockAWSRoleChainingStep_BuildCredentialsCache_Call struct { + *mock.Call +} + +// BuildCredentialsCache is a helper method to define mock.On call +// - client *sts.Client +func (_e *MockAWSRoleChainingStep_Expecter) BuildCredentialsCache(client interface{}) *MockAWSRoleChainingStep_BuildCredentialsCache_Call { + return &MockAWSRoleChainingStep_BuildCredentialsCache_Call{Call: _e.mock.On("BuildCredentialsCache", client)} +} + +func (_c *MockAWSRoleChainingStep_BuildCredentialsCache_Call) Run(run func(client *sts.Client)) *MockAWSRoleChainingStep_BuildCredentialsCache_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *sts.Client + if args[0] != nil { + arg0 = args[0].(*sts.Client) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockAWSRoleChainingStep_BuildCredentialsCache_Call) Return(credentialsCache *aws.CredentialsCache) *MockAWSRoleChainingStep_BuildCredentialsCache_Call { + _c.Call.Return(credentialsCache) + return _c +} + +func (_c *MockAWSRoleChainingStep_BuildCredentialsCache_Call) RunAndReturn(run func(client *sts.Client) *aws.CredentialsCache) *MockAWSRoleChainingStep_BuildCredentialsCache_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/resources/providers/awslib/s3/provider.go b/internal/resources/providers/awslib/s3/provider.go index f2f750896a..d91eed03ca 100644 --- a/internal/resources/providers/awslib/s3/provider.go +++ b/internal/resources/providers/awslib/s3/provider.go @@ -112,6 +112,7 @@ func (p Provider) DescribeBuckets(ctx context.Context) ([]awslib.AwsResource, er BucketVersioning: bucketVersioning, PublicAccessBlockConfiguration: publicAccessBlockConfiguration, AccountPublicAccessBlockConfiguration: accountPublicAccessBlockConfig, + CreationDate: bucket.CreationDate, Region: region, }) } diff --git a/internal/resources/providers/awslib/s3/s3.go b/internal/resources/providers/awslib/s3/s3.go index a89ffc38dd..fe1fe08e2d 100644 --- a/internal/resources/providers/awslib/s3/s3.go +++ b/internal/resources/providers/awslib/s3/s3.go @@ -19,6 +19,7 @@ package s3 import ( "context" + "time" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" @@ -36,6 +37,7 @@ type BucketDescription struct { BucketVersioning *BucketVersioning `json:"bucket_versioning,omitempty"` PublicAccessBlockConfiguration *types.PublicAccessBlockConfiguration `json:"public_access_block_configuration"` AccountPublicAccessBlockConfiguration *s3ContorlTypes.PublicAccessBlockConfiguration `json:"account_public_access_block_configuration"` + CreationDate *time.Time `json:"creation_date,omitempty"` Region string } diff --git a/internal/resources/providers/awslib/tags.go b/internal/resources/providers/awslib/tags.go new file mode 100644 index 0000000000..13384abc6b --- /dev/null +++ b/internal/resources/providers/awslib/tags.go @@ -0,0 +1,33 @@ +// 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 awslib + +import "strings" + +// LookupTag returns the first tag value whose key case-insensitively matches +// one of the provided candidates. Returns "" if no match is found. +func LookupTag(tags map[string]string, candidates ...string) string { + for k, v := range tags { + for _, c := range candidates { + if strings.EqualFold(k, c) { + return v + } + } + } + return "" +}