From cdb455b2b1f2a2474817d1e062396aac0b490e44 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Tue, 30 Sep 2025 19:54:36 +0200 Subject: [PATCH] feat(aws): add new check `ec2_instance_with_outdated_ami` (#6910) Co-authored-by: MrCloudSec --- prowler/CHANGELOG.md | 1 + .../ec2/ec2_ami_public/ec2_ami_public.py | 21 +- .../__init__.py | 0 ...2_instance_with_outdated_ami.metadata.json | 30 +++ .../ec2_instance_with_outdated_ami.py | 52 +++++ .../providers/aws/services/ec2/ec2_service.py | 40 ++-- .../ec2_instance_with_outdated_ami_test.py | 221 ++++++++++++++++++ .../aws/services/ec2/ec2_service_test.py | 25 +- 8 files changed, 358 insertions(+), 32 deletions(-) create mode 100644 prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/__init__.py create mode 100644 prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json create mode 100644 prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.py create mode 100644 tests/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index b1e3d0524b..feb4e8f0b2 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Added - Support for AdditionalURLs in outputs [(#8651)](https://github.com/prowler-cloud/prowler/pull/8651) - Support for markdown metadata fields in Dashboard [(#8667)](https://github.com/prowler-cloud/prowler/pull/8667) +- `ec2_instance_with_outdated_ami` check for AWS provider [(#6910)](https://github.com/prowler-cloud/prowler/pull/6910) - LLM provider using `promptfoo` [(#8555)](https://github.com/prowler-cloud/prowler/pull/8555) - Documentation for renaming checks [(#8717)](https://github.com/prowler-cloud/prowler/pull/8717) - Add explicit "name" field for each compliance framework and include "FRAMEWORK" and "NAME" in CSV output [(#7920)](https://github.com/prowler-cloud/prowler/pull/7920) diff --git a/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.py b/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.py index 47684517ca..3df5fffb58 100644 --- a/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.py +++ b/prowler/providers/aws/services/ec2/ec2_ami_public/ec2_ami_public.py @@ -6,15 +6,16 @@ class ec2_ami_public(Check): def execute(self): findings = [] for image in ec2_client.images: - report = Check_Report_AWS(metadata=self.metadata(), resource=image) - report.status = "PASS" - report.status_extended = ( - f"EC2 AMI {image.name if image.name else image.id} is not public." - ) - if image.public: - report.status = "FAIL" - report.status_extended = f"EC2 AMI {image.name if image.name else image.id} is currently public." + if image.owner == "self": + report = Check_Report_AWS(metadata=self.metadata(), resource=image) + report.status = "PASS" + report.status_extended = ( + f"EC2 AMI {image.name if image.name else image.id} is not public." + ) + if image.public: + report.status = "FAIL" + report.status_extended = f"EC2 AMI {image.name if image.name else image.id} is currently public." - findings.append(report) + findings.append(report) - return findings + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/__init__.py b/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json b/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json new file mode 100644 index 0000000000..8787c0e6f5 --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "aws", + "CheckID": "ec2_instance_with_outdated_ami", + "CheckTitle": "Check for EC2 Instances Using Outdated AMIs", + "CheckType": [], + "ServiceName": "ec2", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:service:region:account-id:instance/resource-id", + "Severity": "high", + "ResourceType": "AwsEc2Instance", + "Description": "This check identifies EC2 instances using outdated Amazon Machine Images (AMIs) by auditing instances to gather AMI IDs, comparing them against the latest available versions, verifying suppo and security update status, and checking for deprecation.", + "Risk": "Using outdated AMIs can expose EC2 instances to security vulnerabilities, lack of support, and missing critical updates, increasing the risk of exploitation.", + "RelatedUrl": "", + "Remediation": { + "Code": { + "CLI": "aws ec2 describe-images --image-ids ", + "NativeIaC": "", + "Other": "https://repost.aws/knowledge-center/ec2-find-deprecated-ami", + "Terraform": "" + }, + "Recommendation": { + "Text": "Regularly update your EC2 instances to use the latest AMIs to ensure they receive the latest security patches and updates.", + "Url": "https://repost.aws/knowledge-center/ec2-find-deprecated-ami" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.py b/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.py new file mode 100644 index 0000000000..bc2dc223fe --- /dev/null +++ b/prowler/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami.py @@ -0,0 +1,52 @@ +from datetime import datetime, timezone +from typing import List + +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.ec2.ec2_client import ec2_client + + +class ec2_instance_with_outdated_ami(Check): + """Check if EC2 instances are using outdated AMIs. + + This check verifies whether EC2 instances are running on outdated AMIs that have + reached their deprecation date. If an instance is using an AMI that is deprecated, + the check fails. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + """ + + def execute(self) -> List[Check_Report_AWS]: + """Execute the outdated AMI check for EC2 instances. + + Iterates over all EC2 instances and checks if their AMIs have been deprecated. + If an instance is using an outdated AMI, the check fails. + + Returns: + List[Check_Report_AWS]: A list containing the results of the check for each instance. + """ + findings = [] + for instance in ec2_client.instances: + ami = next( + (image for image in ec2_client.images if image.id == instance.image_id), + None, + ) + if ami.owner == "amazon": + report = Check_Report_AWS(metadata=self.metadata(), resource=instance) + report.status = "PASS" + report.status_extended = ( + f"EC2 Instance {instance.id} is not using an outdated AMI." + ) + + if ami.deprecation_time: + deprecation_datetime = datetime.strptime( + ami.deprecation_time, "%Y-%m-%dT%H:%M:%S.%fZ" + ).replace(tzinfo=timezone.utc) + + if deprecation_datetime < datetime.now(timezone.utc): + report.status = "FAIL" + report.status_extended = f"EC2 Instance {instance.id} is using outdated AMI {ami.id}." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/ec2/ec2_service.py b/prowler/providers/aws/services/ec2/ec2_service.py index 9559346db4..ccdb9e58fe 100644 --- a/prowler/providers/aws/services/ec2/ec2_service.py +++ b/prowler/providers/aws/services/ec2/ec2_service.py @@ -349,20 +349,30 @@ class EC2(AWSService): def _describe_images(self, regional_client): try: - for image in regional_client.describe_images(Owners=["self"])["Images"]: - arn = f"arn:{self.audited_partition}:ec2:{regional_client.region}:{self.audited_account}:image/{image['ImageId']}" - if not self.audit_resources or ( - is_resource_filtered(arn, self.audit_resources) - ): - self.images.append( - Image( - id=image["ImageId"], - arn=arn, - name=image.get("Name", ""), - public=image.get("Public", False), - region=regional_client.region, - tags=image.get("Tags"), - ) + for owner in ["self", "amazon"]: + try: + for image in regional_client.describe_images( + Owners=[owner], IncludeDeprecated=True + )["Images"]: + arn = f"arn:{self.audited_partition}:ec2:{regional_client.region}:{self.audited_account}:image/{image['ImageId']}" + if not self.audit_resources or ( + is_resource_filtered(arn, self.audit_resources) + ): + self.images.append( + Image( + id=image["ImageId"], + arn=arn, + name=image.get("Name", ""), + public=image.get("Public", False), + region=regional_client.region, + tags=image.get("Tags"), + deprecation_time=image.get("DeprecationTime"), + owner=owner, + ) + ) + except Exception as error: + logger.error( + f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) except Exception as error: logger.error( @@ -746,6 +756,8 @@ class Image(BaseModel): arn: str name: str public: bool + deprecation_time: Optional[str] + owner: str region: str tags: Optional[list] = [] diff --git a/tests/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami_test.py b/tests/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami_test.py new file mode 100644 index 0000000000..81d6a6114f --- /dev/null +++ b/tests/providers/aws/services/ec2/ec2_instance_with_outdated_ami/ec2_instance_with_outdated_ami_test.py @@ -0,0 +1,221 @@ +from unittest import mock + +import botocore +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_US_EAST_1, set_mocked_aws_provider + +make_api_call = botocore.client.BaseClient._make_api_call + + +def mock_make_api_call(self, operation_name, kwarg): + if operation_name == "DescribeInstances": + return { + "Reservations": [ + { + "Instances": [ + { + "InstanceId": "i-0123456789abcdef0", + "State": {"Name": "running"}, + "InstanceType": "t2.micro", + "ImageId": "ami-12345678", + "LaunchTime": "2026-11-12T11:34:56.000Z", + "PrivateDnsName": "ip-172-31-32-101.ec2.internal", + } + ] + } + ] + } + elif operation_name == "DescribeImages": + if "Owners" in kwarg and kwarg["Owners"] == ["amazon"]: + return { + "Images": [ + { + "ImageId": "ami-12345678", + "DeprecationTime": "2050-01-01T00:00:00.000Z", + "Public": True, + } + ] + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_private(self, operation_name, kwarg): + if operation_name == "DescribeInstances": + return { + "Reservations": [ + { + "Instances": [ + { + "InstanceId": "i-0123456789abcdef0", + "State": {"Name": "running"}, + "InstanceType": "t2.micro", + "ImageId": "ami-12345678", + "LaunchTime": "2026-11-12T11:34:56.000Z", + "PrivateDnsName": "ip-172-31-32-101.ec2.internal", + } + ] + } + ] + } + elif operation_name == "DescribeImages": + return { + "Images": [ + { + "ImageId": "ami-12345678", + "DeprecationTime": "2050-01-01T00:00:00.000Z", + "Public": False, + } + ] + } + return make_api_call(self, operation_name, kwarg) + + +def mock_make_api_call_outdated_ami(self, operation_name, kwarg): + if operation_name == "DescribeInstances": + return { + "Reservations": [ + { + "Instances": [ + { + "InstanceId": "i-0123456789abcdef0", + "State": {"Name": "running"}, + "InstanceType": "t2.micro", + "ImageId": "ami-87654321", + "LaunchTime": "2026-11-12T11:34:56.000Z", + "PrivateDnsName": "ip-172-31-32-101.ec2.internal", + } + ] + } + ] + } + elif operation_name == "DescribeImages": + if "Owners" in kwarg and kwarg["Owners"] == ["amazon"]: + return { + "Images": [ + { + "ImageId": "ami-87654321", + "DeprecationTime": "2022-01-01T00:00:00.000Z", + "Public": True, + } + ] + } + return make_api_call(self, operation_name, kwarg) + + +class Test_ec2_instance_with_outdated_ami: + @mock_aws + def test_ec2_no_instances(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami.ec2_client", + new=EC2(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami import ( + ec2_instance_with_outdated_ami, + ) + + check = ec2_instance_with_outdated_ami() + result = check.execute() + + assert len(result) == 0 + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_private + ) + def test_ec2_no_public_images(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami.ec2_client", + new=EC2(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami import ( + ec2_instance_with_outdated_ami, + ) + + check = ec2_instance_with_outdated_ami() + result = check.execute() + + assert len(result) == 0 + + @mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) + def test_instance_ami_not_outdated(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami.ec2_client", + new=EC2(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami import ( + ec2_instance_with_outdated_ami, + ) + + check = ec2_instance_with_outdated_ami() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "i-0123456789abcdef0" + assert ( + result[0].status_extended + == "EC2 Instance i-0123456789abcdef0 is not using an outdated AMI." + ) + + @mock.patch( + "botocore.client.BaseClient._make_api_call", new=mock_make_api_call_outdated_ami + ) + def test_instance_ami_outdated(self): + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami.ec2_client", + new=EC2(aws_provider), + ), + ): + from prowler.providers.aws.services.ec2.ec2_instance_with_outdated_ami.ec2_instance_with_outdated_ami import ( + ec2_instance_with_outdated_ami, + ) + + check = ec2_instance_with_outdated_ami() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == "i-0123456789abcdef0" + assert ( + result[0].status_extended + == "EC2 Instance i-0123456789abcdef0 is using outdated AMI ami-87654321." + ) diff --git a/tests/providers/aws/services/ec2/ec2_service_test.py b/tests/providers/aws/services/ec2/ec2_service_test.py index d7650cb919..aca200dd61 100644 --- a/tests/providers/aws/services/ec2/ec2_service_test.py +++ b/tests/providers/aws/services/ec2/ec2_service_test.py @@ -610,16 +610,21 @@ class Test_EC2_Service: ) ec2 = EC2(aws_provider) - assert len(ec2.images) == 1 - assert ec2.images[0].id == image_id - assert re.match(r"ami-[0-9a-z]{8}", ec2.images[0].id) + # Filter for user-created images (owner="self") + user_images = [img for img in ec2.images if img.owner == "self"] + assert len(user_images) == 1 + + user_image = user_images[0] + assert user_image.id == image_id + assert re.match(r"ami-[0-9a-z]{8}", user_image.id) assert ( - ec2.images[0].arn - == f"arn:{aws_provider.identity.partition}:ec2:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:image/{ec2.images[0].id}" + user_image.arn + == f"arn:{aws_provider.identity.partition}:ec2:{AWS_REGION_US_EAST_1}:{AWS_ACCOUNT_NUMBER}:image/{user_image.id}" ) - assert not ec2.images[0].public - assert ec2.images[0].region == AWS_REGION_US_EAST_1 - assert ec2.images[0].tags == [ + assert user_image.deprecation_time == instance.image.deprecation_time + assert not user_image.public + assert user_image.region == AWS_REGION_US_EAST_1 + assert user_image.tags == [ { "Key": "Base_AMI_Name", "Value": "Deep Learning Base AMI (Amazon Linux 2) Version 31.0", @@ -627,6 +632,10 @@ class Test_EC2_Service: {"Key": "OS_Version", "Value": "AWS Linux 2"}, ] + # Verify that Amazon images are also present + amazon_images = [img for img in ec2.images if img.owner == "amazon"] + assert len(amazon_images) > 0 # Should have Amazon AMIs + # Test EC2 Describe Volumes @mock_aws def test_describe_volumes(self):