Skip to content

Commit f0a31e3

Browse files
jameslaneovermindactions-user
authored andcommitted
feat(aws): add blast propagation from security groups to instances vi… (#3284)
## Summary - Add blast radius propagation from Security Groups to EC2 instances via Network Interfaces - Enable `ec2-network-interface` adapter to search by security group ID using AWS's `group-id` filter - Fix issue where changing a Security Group would not show affected EC2 instances in blast radius ## Problem When analyzing the blast radius of a Security Group change, Overmind wasn't discovering the EC2 instances attached to that security group. This was because: 1. The `ec2-security-group` adapter only linked **outward** to VPCs and other security groups 2. The `ec2-instance` adapter linked **to** security groups with `In: true, Out: false` 3. Since blast radius starts from the changing resource (SG) and follows outward links, instances were never discovered This meant users would see no risks when modifying security groups, even when instances were actively using them. ## Solution Added a forward link from Security Groups → Network Interfaces → Instances: ``` SG change → ec2-network-interface (SEARCH by sg-id) → ec2-instance (existing link with Out: true) ``` Changes: - `ec2-security-group`: Added `LinkedItemQuery` to search for ENIs using this SG - `ec2-network-interface`: Added `InputMapperSearch` that filters by `group-id` when query starts with `sg-` ## Test plan - [x] Unit tests pass for `TestNetworkInterfaceInputMapperSearch` - [x] Unit tests pass for `TestSecurityGroupOutputMapper` with new ENI link - [ ] Manual test: Create SG with attached instances, run change analysis, verify instances appear in blast radius <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Enables blast radius from security groups to instances by linking SGs to ENIs and adding ENI search by security group ID (and ARN). > > - **Adapters** > - `ec2-network-interface`: > - Add `InputMapperSearch` supporting `sg-*` via `group-id` filter and parsing ARN `network-interface/eni-*`. > - Wire `InputMapperSearch` into adapter; update metadata `SearchDescription`. > - `ec2-security-group`: > - Add linked SEARCH to `ec2-network-interface` by SG ID with outward blast propagation. > - Update `PotentialLinks` to include `ec2-network-interface`. > - **Tests** > - Add `TestNetworkInterfaceInputMapperSearch` and extend `TestSecurityGroupOutputMapper` for new ENI link. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 436e0e83739023d73b97f54f16127d3febf09443. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> GitOrigin-RevId: c1f547771af266b06d2d84390444dd171217873b
1 parent f277499 commit f0a31e3

File tree

4 files changed

+156
-4
lines changed

4 files changed

+156
-4
lines changed

aws-source/adapters/ec2-network-interface.go

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package adapters
22

33
import (
44
"context"
5+
"strings"
56

7+
"github.com/aws/aws-sdk-go-v2/aws"
68
"github.com/aws/aws-sdk-go-v2/service/ec2"
9+
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
710

811
"github.com/overmindtech/cli/aws-source/adapterhelpers"
912
"github.com/overmindtech/cli/sdp-go"
@@ -21,6 +24,45 @@ func networkInterfaceInputMapperList(scope string) (*ec2.DescribeNetworkInterfac
2124
return &ec2.DescribeNetworkInterfacesInput{}, nil
2225
}
2326

27+
func networkInterfaceInputMapperSearch(_ context.Context, _ *ec2.Client, scope, query string) (*ec2.DescribeNetworkInterfacesInput, error) {
28+
// If query looks like a security group ID, filter by it
29+
// This enables security groups to discover their attached network interfaces
30+
if strings.HasPrefix(query, "sg-") {
31+
return &ec2.DescribeNetworkInterfacesInput{
32+
Filters: []types.Filter{
33+
{
34+
Name: aws.String("group-id"),
35+
Values: []string{query},
36+
},
37+
},
38+
}, nil
39+
}
40+
41+
// Otherwise try to parse as an ARN
42+
arn, err := adapterhelpers.ParseARN(query)
43+
if err != nil {
44+
return nil, &sdp.QueryError{
45+
ErrorType: sdp.QueryError_NOTFOUND,
46+
ErrorString: "query must be a security group ID (sg-*) or a valid ARN",
47+
Scope: scope,
48+
}
49+
}
50+
51+
// Extract network interface ID from ARN
52+
// ARN format: arn:aws:ec2:region:account:network-interface/eni-xxx
53+
if arn.Type() == "network-interface" {
54+
return &ec2.DescribeNetworkInterfacesInput{
55+
NetworkInterfaceIds: []string{arn.ResourceID()},
56+
}, nil
57+
}
58+
59+
return nil, &sdp.QueryError{
60+
ErrorType: sdp.QueryError_NOTFOUND,
61+
ErrorString: "unsupported ARN type for network interface search",
62+
Scope: scope,
63+
}
64+
}
65+
2466
func networkInterfaceOutputMapper(_ context.Context, _ *ec2.Client, scope string, _ *ec2.DescribeNetworkInterfacesInput, output *ec2.DescribeNetworkInterfacesOutput) ([]*sdp.Item, error) {
2567
items := make([]*sdp.Item, 0)
2668

@@ -252,8 +294,9 @@ func NewEC2NetworkInterfaceAdapter(client *ec2.Client, accountID string, region
252294
DescribeFunc: func(ctx context.Context, client *ec2.Client, input *ec2.DescribeNetworkInterfacesInput) (*ec2.DescribeNetworkInterfacesOutput, error) {
253295
return client.DescribeNetworkInterfaces(ctx, input)
254296
},
255-
InputMapperGet: networkInterfaceInputMapperGet,
256-
InputMapperList: networkInterfaceInputMapperList,
297+
InputMapperGet: networkInterfaceInputMapperGet,
298+
InputMapperList: networkInterfaceInputMapperList,
299+
InputMapperSearch: networkInterfaceInputMapperSearch,
257300
PaginatorBuilder: func(client *ec2.Client, params *ec2.DescribeNetworkInterfacesInput) adapterhelpers.Paginator[*ec2.DescribeNetworkInterfacesOutput, *ec2.Options] {
258301
return ec2.NewDescribeNetworkInterfacesPaginator(client, params)
259302
},
@@ -270,7 +313,7 @@ var networkInterfaceAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{
270313
Search: true,
271314
GetDescription: "Get a network interface by ID",
272315
ListDescription: "List all network interfaces",
273-
SearchDescription: "Search network interfaces by ARN",
316+
SearchDescription: "Search network interfaces by ARN or security group ID (sg-*)",
274317
},
275318
PotentialLinks: []string{"ec2-instance", "ec2-security-group", "ip", "dns", "ec2-subnet", "ec2-vpc"},
276319
TerraformMappings: []*sdp.TerraformMapping{

aws-source/adapters/ec2-network-interface_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,88 @@ func TestNetworkInterfaceInputMapperList(t *testing.T) {
4040
}
4141
}
4242

43+
func TestNetworkInterfaceInputMapperSearch(t *testing.T) {
44+
t.Parallel()
45+
46+
tests := []struct {
47+
name string
48+
query string
49+
expectFilter bool
50+
filterName string
51+
filterValue string
52+
expectENIId bool
53+
eniId string
54+
expectError bool
55+
}{
56+
{
57+
name: "Security group ID",
58+
query: "sg-0437857de45b640ce",
59+
expectFilter: true,
60+
filterName: "group-id",
61+
filterValue: "sg-0437857de45b640ce",
62+
},
63+
{
64+
name: "Network interface ARN",
65+
query: "arn:aws:ec2:eu-west-2:123456789012:network-interface/eni-0b4652e6f2aa36d78",
66+
expectENIId: true,
67+
eniId: "eni-0b4652e6f2aa36d78",
68+
},
69+
{
70+
name: "Invalid query",
71+
query: "invalid-query",
72+
expectError: true,
73+
},
74+
{
75+
name: "Invalid ARN type",
76+
query: "arn:aws:ec2:eu-west-2:123456789012:instance/i-1234567890abcdef0",
77+
expectError: true,
78+
},
79+
}
80+
81+
for _, tt := range tests {
82+
t.Run(tt.name, func(t *testing.T) {
83+
t.Parallel()
84+
85+
input, err := networkInterfaceInputMapperSearch(context.Background(), nil, "123456789012.eu-west-2", tt.query)
86+
87+
if tt.expectError {
88+
if err == nil {
89+
t.Errorf("expected error for query %s, got nil", tt.query)
90+
}
91+
return
92+
}
93+
94+
if err != nil {
95+
t.Errorf("unexpected error for query %s: %v", tt.query, err)
96+
return
97+
}
98+
99+
if tt.expectFilter {
100+
if len(input.Filters) != 1 {
101+
t.Errorf("expected 1 filter, got %d", len(input.Filters))
102+
return
103+
}
104+
if *input.Filters[0].Name != tt.filterName {
105+
t.Errorf("expected filter name %s, got %s", tt.filterName, *input.Filters[0].Name)
106+
}
107+
if len(input.Filters[0].Values) != 1 || input.Filters[0].Values[0] != tt.filterValue {
108+
t.Errorf("expected filter value %s, got %v", tt.filterValue, input.Filters[0].Values)
109+
}
110+
}
111+
112+
if tt.expectENIId {
113+
if len(input.NetworkInterfaceIds) != 1 {
114+
t.Errorf("expected 1 network interface ID, got %d", len(input.NetworkInterfaceIds))
115+
return
116+
}
117+
if input.NetworkInterfaceIds[0] != tt.eniId {
118+
t.Errorf("expected network interface ID %s, got %s", tt.eniId, input.NetworkInterfaceIds[0])
119+
}
120+
}
121+
})
122+
}
123+
}
124+
43125
func TestNetworkInterfaceOutputMapper(t *testing.T) {
44126
output := &ec2.DescribeNetworkInterfacesOutput{
45127
NetworkInterfaces: []types.NetworkInterface{

aws-source/adapters/ec2-security-group.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,27 @@ func securityGroupOutputMapper(_ context.Context, _ *ec2.Client, scope string, _
6464
})
6565
}
6666

67+
// Network Interfaces using this security group
68+
// This enables blast radius propagation from security groups to
69+
// instances via their network interfaces
70+
if securityGroup.GroupId != nil {
71+
item.LinkedItemQueries = append(item.LinkedItemQueries, &sdp.LinkedItemQuery{
72+
Query: &sdp.Query{
73+
Type: "ec2-network-interface",
74+
Method: sdp.QueryMethod_SEARCH,
75+
Query: *securityGroup.GroupId,
76+
Scope: scope,
77+
},
78+
BlastPropagation: &sdp.BlastPropagation{
79+
// Network interfaces don't affect the security group
80+
In: false,
81+
// Changes to the security group affect network interfaces
82+
// (and through them, EC2 instances)
83+
Out: true,
84+
},
85+
})
86+
}
87+
6788
item.LinkedItemQueries = append(item.LinkedItemQueries, extractLinkedSecurityGroups(securityGroup.IpPermissions, scope)...)
6889
item.LinkedItemQueries = append(item.LinkedItemQueries, extractLinkedSecurityGroups(securityGroup.IpPermissionsEgress, scope)...)
6990

@@ -108,7 +129,7 @@ var securityGroupAdapterMetadata = Metadata.Register(&sdp.AdapterMetadata{
108129
ListDescription: "List all security groups",
109130
SearchDescription: "Search for security groups by ARN",
110131
},
111-
PotentialLinks: []string{"ec2-vpc"},
132+
PotentialLinks: []string{"ec2-vpc", "ec2-network-interface"},
112133
TerraformMappings: []*sdp.TerraformMapping{
113134
{TerraformQueryMap: "aws_security_group.id"},
114135
{TerraformQueryMap: "aws_security_group_rule.security_group_id"},

aws-source/adapters/ec2-security-group_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ func TestSecurityGroupOutputMapper(t *testing.T) {
101101
ExpectedQuery: "vpc-0d7892e00e573e701",
102102
ExpectedScope: item.GetScope(),
103103
},
104+
{
105+
ExpectedType: "ec2-network-interface",
106+
ExpectedMethod: sdp.QueryMethod_SEARCH,
107+
ExpectedQuery: "sg-094e151c9fc5da181",
108+
ExpectedScope: item.GetScope(),
109+
},
104110
{
105111
ExpectedType: "ec2-security-group",
106112
ExpectedMethod: sdp.QueryMethod_GET,

0 commit comments

Comments
 (0)