Repository: localstack/localstack
Pull request #12615: Add EC2 support for GetSecurityGroupsForVpc API operation (#12602)
Author: login: iamramtin
Labels: name: semver: minor
description: Non-breaking changes which can be included in minor releases, but not in patch releases
Created: 2025-05-13T15:59:04Z | Merged: 2025-05-20T10:15:31Z

<!-- Please refer to the contribution guidelines before raising a PR: https://github.com/localstack/localstack/blob/master/docs/CONTRIBUTING.md -->

<!-- Why am I raising this PR? Add context such as related issues, PRs, or documentation. -->
## Motivation
This PR adds support for the EC2 API's `GetSecurityGroupsForVpc` operation, which retrieves security groups associated with a specified VPC. This operation provides a convenient way to filter security groups by VPC ID, without having to use the more generic filters syntax of `DescribeSecurityGroups`.


<!-- What changes does this PR make? How does LocalStack behave differently now? -->
## Changes

- Added implementation for the `GetSecurityGroupsForVpc` EC2 API operation
- Built the implementation to leverage the existing `DescribeSecurityGroups` functionality with a VPC filter
- Added integration tests to verify both normal operation and edge cases
- Ensured proper validation for empty VPC IDs
- Made sure the response format matches AWS API specifications

## Testing
You can test this new functionality in the following way:

```
# Create a VPC and capture its ID
VPC_ID=$(awslocal ec2 create-vpc --cidr-block 10.0.0.0/16 --query Vpc.VpcId --output text)

# Create a security group in the VPC
awslocal ec2 create-security-group --group-name my-sg --description "My security group" --vpc-id $VPC_ID

# Filter security groups by VPC ID using describe-security-groups
awslocal ec2 describe-security-groups --filters Name=vpc-id,Values=$VPC_ID

# Use the new get-security-groups-for-vpc operation (should do the same as above)
awslocal ec2 get-security-groups-for-vpc --vpc-id $VPC_ID
```

Additionally, the PR includes integration tests that verify regular functionality and edge cases.


<!-- Optional section: What's left to do before it can be merged? -->
<!--
## TODO

What's left to do:

- [ ] ...
- [ ] ...
-->



Diff:
diff --git a/localstack-core/localstack/services/ec2/provider.py b/localstack-core/localstack/services/ec2/provider.py
index 59a560cd7295e..401276a0da7bd 100644
--- a/localstack-core/localstack/services/ec2/provider.py
+++ b/localstack-core/localstack/services/ec2/provider.py
@@ -50,6 +50,8 @@
     DnsOptionsSpecification,
     DnsRecordIpType,
     Ec2Api,
+    GetSecurityGroupsForVpcRequest,
+    GetSecurityGroupsForVpcResult,
     InstanceType,
     IpAddressType,
     LaunchTemplate,
@@ -70,6 +72,7 @@
     RevokeSecurityGroupEgressRequest,
     RevokeSecurityGroupEgressResult,
     RIProductDescription,
+    SecurityGroupForVpc,
     String,
     SubnetConfigurationsList,
     Tenancy,
@@ -539,6 +542,30 @@ def create_flow_logs(
 
         return response
 
+    @handler("GetSecurityGroupsForVpc", expand=False)
+    def get_security_groups_for_vpc(
+        self,
+        context: RequestContext,
+        get_security_groups_for_vpc_request: GetSecurityGroupsForVpcRequest,
+    ) -> GetSecurityGroupsForVpcResult:
+        vpc_id = get_security_groups_for_vpc_request.get("VpcId")
+        backend = get_ec2_backend(context.account_id, context.region)
+        filters = {"vpc-id": [vpc_id]}
+        filtered_sgs = backend.describe_security_groups(filters=filters)
+
+        sgs = [
+            SecurityGroupForVpc(
+                Description=sg.description,
+                GroupId=sg.id,
+                GroupName=sg.name,
+                OwnerId=context.account_id,
+                PrimaryVpcId=sg.vpc_id,
+                Tags=[{"Key": tag.get("key"), "Value": tag.get("value")} for tag in sg.get_tags()],
+            )
+            for sg in filtered_sgs
+        ]
+        return GetSecurityGroupsForVpcResult(SecurityGroupForVpcs=sgs, NextToken=None)
+
 
 @patch(SubnetBackend.modify_subnet_attribute)
 def modify_subnet_attribute(fn, self, subnet_id, attr_name, attr_value):
diff --git a/localstack-core/localstack/testing/pytest/fixtures.py b/localstack-core/localstack/testing/pytest/fixtures.py
index 6bddcb162632f..b89d5aedf2a87 100644
--- a/localstack-core/localstack/testing/pytest/fixtures.py
+++ b/localstack-core/localstack/testing/pytest/fixtures.py
@@ -2009,11 +2009,16 @@ def factory(ports=None, ip_protocol: str = "tcp", **kwargs):
         :param ip_protocol: the ip protocol for the permissions (tcp by default)
         """
         if "GroupName" not in kwargs:
+            # FIXME: This will fail against AWS since the sg prefix is not valid for GroupName
+            # > "Group names may not be in the format sg-*".
             kwargs["GroupName"] = f"sg-{short_uid()}"
         # Making sure the call to CreateSecurityGroup gets the right arguments
         _args = select_from_typed_dict(CreateSecurityGroupRequest, kwargs)
         security_group = aws_client.ec2.create_security_group(**_args)
         security_group_id = security_group["GroupId"]
+
+        # FIXME: If 'ports' is None or an empty list, authorize_security_group_ingress will fail due to missing IpPermissions.
+        # Must ensure ports are explicitly provided or skip authorization entirely if not required.
         permissions = [
             {
                 "FromPort": port,
diff --git a/tests/aws/services/ec2/test_ec2.py b/tests/aws/services/ec2/test_ec2.py
index f0ba136034454..cd006fa90abb7 100644
--- a/tests/aws/services/ec2/test_ec2.py
+++ b/tests/aws/services/ec2/test_ec2.py
@@ -3,6 +3,7 @@
 
 import pytest
 from botocore.exceptions import ClientError
+from localstack_snapshot.snapshots.transformer import SortingTransformer
 from moto.ec2 import ec2_backends
 from moto.ec2.utils import (
     random_security_group_id,
@@ -694,6 +695,71 @@ def _create_security_group() -> dict:
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidSecurityGroupId.DuplicateCustomId"
 
+    @markers.snapshot.skip_snapshot_verify(
+        paths=[
+            "$..Tags",  # Tags can differ between environments
+            "$..Vpc.IsDefault",  # TODO: CreateVPC should return an IsDefault param
+            "$..Vpc.DhcpOptionsId",  # FIXME: DhcpOptionsId uses different reference formats in AWS vs LocalStack
+        ]
+    )
+    @markers.aws.validated
+    def test_get_security_groups_for_vpc(
+        self, snapshot, aws_client, create_vpc, ec2_create_security_group
+    ):
+        group_name = f"test-security-group-{short_uid()}"
+        group_description = f"Description for {group_name}"
+
+        # Returned security groups appear to be sorted by the randomly generated GroupId field,
+        # so we should sort snapshots by this value to mitigate flakiness for runs against AWS.
+        snapshot.add_transformer(
+            SortingTransformer("SecurityGroupForVpcs", lambda x: x["GroupName"])
+        )
+        snapshot.add_transformer(snapshot.transform.key_value("GroupId"))
+        snapshot.add_transformer(snapshot.transform.key_value("GroupName"))
+        snapshot.add_transformer(snapshot.transform.key_value("VpcId"))
+        snapshot.add_transformer(snapshot.transform.key_value("AssociationId"))
+        snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId"))
+
+        # Create VPC for testing
+        vpc: dict = create_vpc(
+            cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "test-key", "Value": "test-value"},
+                    ],
+                }
+            ],
+        )
+        vpc_id: str = vpc["Vpc"]["VpcId"]
+        snapshot.match("create_vpc_response", vpc)
+
+        # Wait to ensure VPC is available
+        waiter = aws_client.ec2.get_waiter("vpc_available")
+        waiter.wait(VpcIds=[vpc_id])
+
+        # Get all security groups in the VPC
+        get_security_groups_for_vpc = aws_client.ec2.get_security_groups_for_vpc(VpcId=vpc_id)
+        snapshot.match("get_security_groups_for_vpc", get_security_groups_for_vpc)
+
+        # Create new security group in the VPC
+        create_security_group = ec2_create_security_group(
+            GroupName=group_name,
+            Description=group_description,
+            VpcId=vpc_id,
+            ports=[22],  # TODO: Handle port issues in the fixture
+        )
+        snapshot.match("create_security_group", create_security_group)
+
+        # Ensure new security group is in the VPC
+        get_security_groups_for_vpc_after_addition = aws_client.ec2.get_security_groups_for_vpc(
+            VpcId=vpc_id
+        )
+        snapshot.match(
+            "get_security_groups_for_vpc_after_addition", get_security_groups_for_vpc_after_addition
+        )
+
 
 @markers.snapshot.skip_snapshot_verify(
     # Moto and LS do not return the ClientToken
diff --git a/tests/aws/services/ec2/test_ec2.snapshot.json b/tests/aws/services/ec2/test_ec2.snapshot.json
index 3347bf78d1bdb..c76d1b6969e9a 100644
--- a/tests/aws/services/ec2/test_ec2.snapshot.json
+++ b/tests/aws/services/ec2/test_ec2.snapshot.json
@@ -335,5 +335,89 @@
         }
       }
     }
+  },
+  "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_get_security_groups_for_vpc": {
+    "recorded-date": "19-05-2025, 13:53:56",
+    "recorded-content": {
+      "create_vpc_response": {
+        "Vpc": {
+          "CidrBlock": "10.0.0.0/16",
+          "CidrBlockAssociationSet": [
+            {
+              "AssociationId": "<association-id:1>",
+              "CidrBlock": "10.0.0.0/16",
+              "CidrBlockState": {
+                "State": "associated"
+              }
+            }
+          ],
+          "DhcpOptionsId": "<dhcp-options-id:1>",
+          "InstanceTenancy": "<group-name:1>",
+          "Ipv6CidrBlockAssociationSet": [],
+          "IsDefault": false,
+          "OwnerId": "111111111111",
+          "State": "pending",
+          "Tags": [
+            {
+              "Key": "test-key",
+              "Value": "test-value"
+            }
+          ],
+          "VpcId": "<vpc-id:1>"
+        },
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get_security_groups_for_vpc": {
+        "SecurityGroupForVpcs": [
+          {
+            "Description": "<group-name:1> VPC security group",
+            "GroupId": "<group-id:1>",
+            "GroupName": "<group-name:1>",
+            "OwnerId": "111111111111",
+            "PrimaryVpcId": "<vpc-id:1>",
+            "Tags": []
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "create_security_group": {
+        "GroupId": "<group-id:2>",
+        "SecurityGroupArn": "arn:<partition>:ec2:<region>:111111111111:security-group/<group-id:2>",
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      },
+      "get_security_groups_for_vpc_after_addition": {
+        "SecurityGroupForVpcs": [
+          {
+            "Description": "<group-name:1> VPC security group",
+            "GroupId": "<group-id:1>",
+            "GroupName": "<group-name:1>",
+            "OwnerId": "111111111111",
+            "PrimaryVpcId": "<vpc-id:1>",
+            "Tags": []
+          },
+          {
+            "Description": "Description for <group-name:2>",
+            "GroupId": "<group-id:2>",
+            "GroupName": "<group-name:2>",
+            "OwnerId": "111111111111",
+            "PrimaryVpcId": "<vpc-id:1>",
+            "Tags": []
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
+      }
+    }
   }
 }
diff --git a/tests/aws/services/ec2/test_ec2.validation.json b/tests/aws/services/ec2/test_ec2.validation.json
index 2a599a8011508..19e617efc962a 100644
--- a/tests/aws/services/ec2/test_ec2.validation.json
+++ b/tests/aws/services/ec2/test_ec2.validation.json
@@ -11,6 +11,9 @@
   "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_describe_vpn_gateways_filter_by_vpc": {
     "last_validated_date": "2024-06-07T01:11:12+00:00"
   },
+  "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_get_security_groups_for_vpc": {
+    "last_validated_date": "2025-05-19T13:54:09+00:00"
+  },
   "tests/aws/services/ec2/test_ec2.py::TestEc2Integrations::test_vcp_peering_difference_regions": {
     "last_validated_date": "2024-06-07T21:28:25+00:00"
   },


Reviews:
user: login: localstack-bot
body: Welcome to LocalStack! Thanks for raising your first Pull Request and landing in your contributions. Our team will reach out with any reviews or feedbacks that we have shortly. We recommend joining our [Slack Community](https://localstack.cloud/slack/) and share your PR on the **#community** channel to share your contributions with us. Please make sure you are following our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [Code of Conduct](https://github.com/localstack/.github/blob/main/CODE_OF_CONDUCT.md).
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T15:59:13Z
user: login: viren-nadkarni
body: Hi @iamramtin, thanks for raising the PR! I have added my review comments below. I will also add the semver label.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T11:23:23Z
user: login: iamramtin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:13:16Z
user: login: iamramtin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:26:04Z
user: login: viren-nadkarni
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-15T06:53:32Z
user: login: viren-nadkarni
body: Hi @iamramtin, the implementation currently breaks when a security group has tags. Could you please fix it and include the necessary test cases?

```
$ awslocal ec2 create-vpc --cidr-block 10.1.0.0/16 --query Vpc.VpcId
"vpc-596131631cff9cea1"

$ awslocal ec2 create-security-group --group-name hello --description world --vpc vpc-596131631cff9cea1 --tag-specifications "ResourceType=security-group,Tags=[{Key=colour,Value=blue}]"{
    "GroupId": "sg-cbf52384c65ddc0b6",
    "Tags": [
        {
            "Key": "colour",
            "Value": "blue"
        }
    ],
    "SecurityGroupArn": "arn:aws:ec2:eu-central-1:000000000000:security-group/sg-cbf52384c65ddc0b6"
}

$ awslocal ec2 get-security-groups-for-vpc --vpc-id vpc-596131631cff9cea1

An error occurred (InternalError) when calling the GetSecurityGroupsForVpc operation (reached max retries: 4): exception while calling ec2.GetSecurityGroupsForVpc: Traceback (most recent call last):
  File "/home/viren/.virtualenvs/ls_venv/lib/python3.11/site-packages/rolo/gateway/chain.py", line 166, in handle
    handler(self, self.context, response)
  File "/home/viren/repo/localstack/localstack-core/localstack/aws/handlers/service.py", line 113, in __call__
    handler(chain, context, response)
  File "/home/viren/repo/localstack/localstack-core/localstack/aws/handlers/service.py", line 83, in __call__
    skeleton_response = self.skeleton.invoke(context)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/viren/repo/localstack/localstack-core/localstack/aws/skeleton.py", line 154, in invoke
    return self.dispatch_request(serializer, context, instance)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/viren/repo/localstack/localstack-core/localstack/aws/skeleton.py", line 168, in dispatch_request
    result = handler(context, instance) or {}
             ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/viren/repo/localstack/localstack-core/localstack/aws/forwarder.py", line 133, in _call
    return handler(context, req)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/home/viren/repo/localstack/localstack-core/localstack/aws/skeleton.py", line 118, in __call__
    return self.fn(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/viren/repo/localstack/localstack-core/localstack/aws/api/core.py", line 163, in operation_marker
    return fn(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^
  File "/home/viren/repo/localstack/localstack-core/localstack/services/ec2/provider.py", line 556, in get_security_groups_for_vpc
    sgs = [
          ^
  File "/home/viren/repo/localstack/localstack-core/localstack/services/ec2/provider.py", line 563, in <listcomp>
    Tags=[{"Key": k, "Value": v} for k, v in (sg.get_tags() or {}).items()],
                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'list' object has no attribute 'items'
```

state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-19T10:59:53Z
user: login: viren-nadkarni
body: Thanks for bearing with me. If you could clarify these last concerns, we can merge this.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-19T13:25:21Z
user: login: iamramtin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T13:51:13Z
user: login: iamramtin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T13:52:00Z
user: login: iamramtin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T13:52:32Z
user: login: viren-nadkarni
body: Looks good, thanks for the contribution @iamramtin!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T10:11:23Z

Review comments:
diff_hunk: @@ -539,6 +542,33 @@ def create_flow_logs(
 
         return response
 
+    @handler("GetSecurityGroupsForVpc", expand=False)
+    def get_security_groups_for_vpc(
+        self,
+        context: RequestContext,
+        get_security_groups_for_vpc_request: GetSecurityGroupsForVpcRequest,
+    ) -> GetSecurityGroupsForVpcResult:
+        vpc_id = get_security_groups_for_vpc_request.get("VpcId")
+        if vpc_id:
path: localstack-core/localstack/services/ec2/provider.py
user: login: viren-nadkarni
body: `VpcId` is a [required parameter](https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_GetSecurityGroupsForVpc.html). The conditional must be removed.
created_at: 2025-05-14T11:14:44Z
author_association: MEMBER
diff_hunk: @@ -539,6 +542,33 @@ def create_flow_logs(
 
         return response
 
+    @handler("GetSecurityGroupsForVpc", expand=False)
+    def get_security_groups_for_vpc(
+        self,
+        context: RequestContext,
+        get_security_groups_for_vpc_request: GetSecurityGroupsForVpcRequest,
+    ) -> GetSecurityGroupsForVpcResult:
+        vpc_id = get_security_groups_for_vpc_request.get("VpcId")
+        if vpc_id:
+            next_token = get_security_groups_for_vpc_request.get("NextToken", None)
path: localstack-core/localstack/services/ec2/provider.py
user: login: viren-nadkarni
body: `NextToken` is used for pagination, which is not implemented for this operation. A null value must be returned.
created_at: 2025-05-14T11:16:05Z
author_association: MEMBER
diff_hunk: @@ -482,6 +482,87 @@ def test_create_vpc_with_custom_id(self, aws_client, create_vpc):
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidVpc.DuplicateCustomId"
 
+    @markers.aws.only_localstack
+    def test_get_security_groups_for_vpc(self, cleanups, aws_client, create_vpc):
+        # Create a VPC
+        vpc: dict = create_vpc(
+            cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "Name", "Value": "test-vpc"},
+                    ],
+                }
+            ],
+        )
+        vpc_id: str = vpc["Vpc"]["VpcId"]
+
+        # Create security groups in the VPC
+        sg1_response = aws_client.ec2.create_security_group(
path: tests/aws/services/ec2/test_ec2.py
user: login: viren-nadkarni
body: Please use the `ec2_create_security_group` fixture
created_at: 2025-05-14T11:17:25Z
author_association: MEMBER
diff_hunk: @@ -482,6 +482,87 @@ def test_create_vpc_with_custom_id(self, aws_client, create_vpc):
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidVpc.DuplicateCustomId"
 
+    @markers.aws.only_localstack
path: tests/aws/services/ec2/test_ec2.py
user: login: viren-nadkarni
body: Ideally we want to verify both tests against AWS with snapshots. Take a look [here](https://blog.localstack.cloud/2022-08-04-parity-explained/) for more details. This is both for responses and exceptions. You can find several examples in other tests.
created_at: 2025-05-14T11:20:51Z
author_association: MEMBER
diff_hunk: @@ -482,6 +482,87 @@ def test_create_vpc_with_custom_id(self, aws_client, create_vpc):
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidVpc.DuplicateCustomId"
 
+    @markers.aws.only_localstack
+    def test_get_security_groups_for_vpc(self, cleanups, aws_client, create_vpc):
+        # Create a VPC
+        vpc: dict = create_vpc(
+            cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "Name", "Value": "test-vpc"},
+                    ],
+                }
+            ],
+        )
+        vpc_id: str = vpc["Vpc"]["VpcId"]
+
+        # Create security groups in the VPC
+        sg1_response = aws_client.ec2.create_security_group(
path: tests/aws/services/ec2/test_ec2.py
user: login: iamramtin
body: I tried to use the `ec2_create_security_group` fixture as requested, but I ran into an issue with writing the parity tests because it uses `GroupName` instead of `GroupId` in the `authorize_security_group_ingress` call, which fails when security groups with the same name exist in different VPCs.

I believe the error occurs because AWS requires using `GroupId` to be unique across security groups across VPCs, whereas `GroupName` only needs to be unique within a single VPC.

As a part of this PR, I've updated it to use `GroupId` instead of `GroupName`:

```
# Current implementation:
aws_client.ec2.authorize_security_group_ingress(
    GroupName=kwargs["GroupName"],
    IpPermissions=permissions,
)

# Suggested fix:
aws_client.ec2.authorize_security_group_ingress(
    GroupId=security_group["GroupId"],
    IpPermissions=permissions,
)
```

Please let me know if this is the right approach, otherwise I can revert it and create the security groups directly instead of the fixture (which works as well).
created_at: 2025-05-14T20:13:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -482,6 +482,87 @@ def test_create_vpc_with_custom_id(self, aws_client, create_vpc):
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidVpc.DuplicateCustomId"
 
+    @markers.aws.only_localstack
+    def test_get_security_groups_for_vpc(self, cleanups, aws_client, create_vpc):
+        # Create a VPC
+        vpc: dict = create_vpc(
+            cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "Name", "Value": "test-vpc"},
+                    ],
+                }
+            ],
+        )
+        vpc_id: str = vpc["Vpc"]["VpcId"]
+
+        # Create security groups in the VPC
+        sg1_response = aws_client.ec2.create_security_group(
path: tests/aws/services/ec2/test_ec2.py
user: login: iamramtin
body: Nvm the above, I see a commit was pushed yesterday that resolves this
created_at: 2025-05-14T20:26:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -694,6 +694,84 @@ def _create_security_group() -> dict:
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidSecurityGroupId.DuplicateCustomId"
 
+    @markers.snapshot.skip_snapshot_verify(
+        paths=[
+            "$..Tags",
+            "$..SecurityGroupForVpcs..Description",
+            "$..SecurityGroupForVpcs..GroupId",
+            "$..SecurityGroupForVpcs..GroupName",
+        ]
+    )
+    @markers.aws.validated
+    def test_get_security_groups_for_vpc(
+        self, snapshot, cleanups, aws_client, create_vpc, ec2_create_security_group
+    ):
+        snapshot.add_transformers_list(
+            [
+                snapshot.transform.key_value("GroupId"),
+                snapshot.transform.key_value("SecurityGroupArn"),
+                snapshot.transform.key_value("vpc-id"),
+            ]
+        )
+
+        # Create a VPC
+        vpc: dict = create_vpc(
+            cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "Name", "Value": "test-vpc"},
+                    ],
+                }
+            ],
+        )
+        vpc_id: str = vpc["Vpc"]["VpcId"]
+        snapshot.match("create_vpc", {"vpc-id": vpc_id})
path: tests/aws/services/ec2/test_ec2.py
user: login: viren-nadkarni
body: This test isn't asserting the create VPC operation or the fixture, so this should be omitted.
created_at: 2025-05-15T06:48:01Z
author_association: MEMBER
diff_hunk: @@ -694,6 +694,84 @@ def _create_security_group() -> dict:
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidSecurityGroupId.DuplicateCustomId"
 
+    @markers.snapshot.skip_snapshot_verify(
+        paths=[
+            "$..Tags",
+            "$..SecurityGroupForVpcs..Description",
+            "$..SecurityGroupForVpcs..GroupId",
+            "$..SecurityGroupForVpcs..GroupName",
+        ]
+    )
+    @markers.aws.validated
+    def test_get_security_groups_for_vpc(
+        self, snapshot, cleanups, aws_client, create_vpc, ec2_create_security_group
+    ):
+        snapshot.add_transformers_list(
+            [
+                snapshot.transform.key_value("GroupId"),
+                snapshot.transform.key_value("SecurityGroupArn"),
+                snapshot.transform.key_value("vpc-id"),
+            ]
+        )
+
+        # Create a VPC
+        vpc: dict = create_vpc(
+            cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "Name", "Value": "test-vpc"},
+                    ],
+                }
+            ],
+        )
+        vpc_id: str = vpc["Vpc"]["VpcId"]
+        snapshot.match("create_vpc", {"vpc-id": vpc_id})
+
+        # Create security groups in the VPC
+        sg1 = ec2_create_security_group(
+            GroupName="test-security-group-1",
path: tests/aws/services/ec2/test_ec2.py
user: login: viren-nadkarni
body: Please use randomly generated resource names. See [R09](https://github.com/localstack/localstack/tree/master/docs/testing#r09)

You can have the snapshot transformer handle it.
created_at: 2025-05-15T06:50:39Z
author_association: MEMBER
diff_hunk: @@ -694,6 +694,84 @@ def _create_security_group() -> dict:
         assert e.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
         assert e.value.response["Error"]["Code"] == "InvalidSecurityGroupId.DuplicateCustomId"
 
+    @markers.snapshot.skip_snapshot_verify(
+        paths=[
+            "$..Tags",
+            "$..SecurityGroupForVpcs..Description",
+            "$..SecurityGroupForVpcs..GroupId",
+            "$..SecurityGroupForVpcs..GroupName",
+        ]
+    )
path: tests/aws/services/ec2/test_ec2.py
user: login: viren-nadkarni
body: Generally paths are skipped only if they are not implemented in LocalStack. Why are these skipped?
created_at: 2025-05-15T06:52:58Z
author_association: MEMBER
diff_hunk: @@ -719,10 +722,21 @@ def test_get_security_groups_for_vpc(
         snapshot.add_transformer(snapshot.transform.key_value("VpcId"))
         snapshot.add_transformer(snapshot.transform.key_value("AssociationId"))
         snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId"))
+        snapshot.add_transformer(snapshot.transform.key_value("Key"))
+        snapshot.add_transformer(snapshot.transform.key_value("Value"))
 
         # Create VPC for testing
         vpc: dict = create_vpc(
             cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "Name", "Value": vpc_name},
path: tests/aws/services/ec2/test_ec2.py
user: login: viren-nadkarni
body: Try to avoid the value `Name` in `Key` attribute because it seems to mess up the snapshots.

```suggestion
                        {"Key": "SomethingElse", "Value": vpc_name},
```
created_at: 2025-05-19T13:14:45Z
author_association: MEMBER
diff_hunk: @@ -719,10 +722,21 @@ def test_get_security_groups_for_vpc(
         snapshot.add_transformer(snapshot.transform.key_value("VpcId"))
         snapshot.add_transformer(snapshot.transform.key_value("AssociationId"))
         snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId"))
+        snapshot.add_transformer(snapshot.transform.key_value("Key"))
+        snapshot.add_transformer(snapshot.transform.key_value("Value"))
path: tests/aws/services/ec2/test_ec2.py
user: login: viren-nadkarni
body: Only the attributes that return randomised values must be transformed. Tags are not random, they are same as the ones requested and must not be transformed.
created_at: 2025-05-19T13:16:28Z
author_association: MEMBER
diff_hunk: @@ -560,7 +560,14 @@ def get_security_groups_for_vpc(
                 GroupName=sg.name,
                 OwnerId=context.account_id,
                 PrimaryVpcId=sg.vpc_id,
-                Tags=[{"Key": k, "Value": v} for k, v in (sg.get_tags() or {}).items()],
+                Tags=[
+                    {
+                        "Key": tag.get("key") or tag.get("Key"),
+                        "Value": tag.get("value") or tag.get("Value"),
+                    }
+                    for tag in (sg.get_tags() or [])
path: localstack-core/localstack/services/ec2/provider.py
user: login: viren-nadkarni
body: `get_tags()` [returns a list](https://github.com/getmoto/moto/blob/master/moto/ec2/models/core.py#L9-L13) so the conditional is redundant

```suggestion
                    for tag in sg.get_tags()
```
created_at: 2025-05-19T13:19:23Z
author_association: MEMBER
diff_hunk: @@ -560,7 +560,14 @@ def get_security_groups_for_vpc(
                 GroupName=sg.name,
                 OwnerId=context.account_id,
                 PrimaryVpcId=sg.vpc_id,
-                Tags=[{"Key": k, "Value": v} for k, v in (sg.get_tags() or {}).items()],
+                Tags=[
+                    {
+                        "Key": tag.get("key") or tag.get("Key"),
+                        "Value": tag.get("value") or tag.get("Value"),
+                    }
+                    for tag in (sg.get_tags() or [])
+                    if ("key" in tag or "Key" in tag) and ("value" in tag or "Value" in tag)
path: localstack-core/localstack/services/ec2/provider.py
user: login: viren-nadkarni
body: Moto uses `key` and `value` fields (lowercase first characters).

Could you explain the need for capitalised field names?

https://github.com/getmoto/moto/blob/e3a5de7390fee8465098ee8179eb242b1c644ccf/moto/ec2/models/tags.py#L129-L130
created_at: 2025-05-19T13:23:55Z
author_association: MEMBER
diff_hunk: @@ -560,7 +560,14 @@ def get_security_groups_for_vpc(
                 GroupName=sg.name,
                 OwnerId=context.account_id,
                 PrimaryVpcId=sg.vpc_id,
-                Tags=[{"Key": k, "Value": v} for k, v in (sg.get_tags() or {}).items()],
+                Tags=[
+                    {
+                        "Key": tag.get("key") or tag.get("Key"),
+                        "Value": tag.get("value") or tag.get("Value"),
+                    }
+                    for tag in (sg.get_tags() or [])
+                    if ("key" in tag or "Key" in tag) and ("value" in tag or "Value" in tag)
path: localstack-core/localstack/services/ec2/provider.py
user: login: iamramtin
body: I saw in other implementations the `Key` and `Value` fields were being processed with uppercase letters but those might not have been for Moto functionality... I have confirmed this only needs lower case, so I've updated the process in the provider file
created_at: 2025-05-19T13:51:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -719,10 +722,21 @@ def test_get_security_groups_for_vpc(
         snapshot.add_transformer(snapshot.transform.key_value("VpcId"))
         snapshot.add_transformer(snapshot.transform.key_value("AssociationId"))
         snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId"))
+        snapshot.add_transformer(snapshot.transform.key_value("Key"))
+        snapshot.add_transformer(snapshot.transform.key_value("Value"))
path: tests/aws/services/ec2/test_ec2.py
user: login: iamramtin
body: I was using `short_uid` to generate the tags so they were random and that's why I was transforming them, but since that's not necessary I've just hardcoded `Key` and `Value` and thus removed the transform, let me know if that's okay
created_at: 2025-05-19T13:52:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -719,10 +722,21 @@ def test_get_security_groups_for_vpc(
         snapshot.add_transformer(snapshot.transform.key_value("VpcId"))
         snapshot.add_transformer(snapshot.transform.key_value("AssociationId"))
         snapshot.add_transformer(snapshot.transform.key_value("DhcpOptionsId"))
+        snapshot.add_transformer(snapshot.transform.key_value("Key"))
+        snapshot.add_transformer(snapshot.transform.key_value("Value"))
 
         # Create VPC for testing
         vpc: dict = create_vpc(
             cidr_block="10.0.0.0/16",
+            tag_specifications=[
+                {
+                    "ResourceType": "vpc",
+                    "Tags": [
+                        {"Key": "Name", "Value": vpc_name},
path: tests/aws/services/ec2/test_ec2.py
user: login: iamramtin
body: I've opted to just remove this tag since another test tag is already being created so it's not actually necessary
created_at: 2025-05-19T13:52:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: localstack-bot
created_at: 2025-05-13T15:59:15Z
body: All contributors have signed the CLA  ✍️ ✅<br/><sub>Posted by the ****CLA Assistant Lite bot****.</sub>
author_association: CONTRIBUTOR
user: login: iamramtin
created_at: 2025-05-13T16:44:04Z
body: I have read the CLA Document and I hereby sign the CLA
author_association: CONTRIBUTOR
user: login: iamramtin
created_at: 2025-05-14T10:37:40Z
body: How can I add label `semver: minor`?
author_association: CONTRIBUTOR
user: login: iamramtin
created_at: 2025-05-18T18:39:13Z
body: @viren-nadkarni thanks for your review. I took a closer look at the other snapshot tests in the codebase and reworked my approach. I've removed the assertions and now rely on the snapshot framework for validation similar to other parity tests.

I've also added some FIXME/TODOs to highlight potential issues / inconsistencies I came across. Let me know if this looks good, or if you'd prefer any further adjustments to the testing approach.
author_association: CONTRIBUTOR