DEV Community

Cover image for Automating On-demand GuardDuty EC2 malware scans
Hussein Ayoub
Hussein Ayoub

Posted on

Automating On-demand GuardDuty EC2 malware scans

In this post, I'll automate the initiation of EC2 malware scans by GuardDuty, using a simple AWS SAM template.

Prerequisites

  • All EC2 instances to be scanned must be encrypted with AWS KMS CMK

  • In case you need to modify the KMS encryption key of your existing EBS volume, check out the following resource for more insights

  • You need the necessary IAM permissions to deploy an AWS SAM application

Walkthrough

We will create a new AWS SAM template file and include the following block of YAML definition to define our Lambda Function responsible of triggering the scans:

AWSTemplateFormatVersion: '2010-09-09' Transform: AWS::Serverless-2016-10-31 Description: > python3.13 Resources: EC2MalwareScan: Type: AWS::Serverless::Function Properties: FunctionName: ec2-malware-scan-weekly Description: Initiates GuardDuty on-demand malware scans for running EC2 instances PackageType: Zip Runtime: python3.13 Handler: ec2_malware_scan_guardduty.ec2_malware_scan CodeUri: lambdas/infrastructure/ec2_malware_scan/ Timeout: 60 MemorySize: 256 Tracing: Active LoggingConfig: LogFormat: JSON # CodeSigningConfigArn: !Ref CodeSigningConfig # Optional if you're using code signing already Architectures: - x86_64 Events: WeeklySchedule: Type: ScheduleV2 Properties: ScheduleExpression: 'cron(0 6 ? * MON *)' Name: WeeklyEC2MalwareScan Description: Weekly EC2 malware scan every Monday at 6 AM UTC State: ENABLED RetryPolicy: MaximumEventAgeInSeconds: 3600 MaximumRetryAttempts: 2 Policies: - Statement: - Sid: Ec2Describe Effect: Allow Action: ec2:DescribeInstances Resource: "*" - Sid: GuardDutyScanOnly Effect: Allow Action: - guardduty:ListDetectors - guardduty:GetDetector - guardduty:StartMalwareScan Resource: "*" - Sid: IAMPermissions Effect: Allow Action: - iam:GetRole - iam:PassRole Resource: "arn:aws:iam::*:role/aws-service-role/malware-protection.guardduty.amazonaws.com/AWSServiceRoleForAmazonGuardDutyMalwareProtection" - Sid: StsCaller Effect: Allow Action: sts:GetCallerIdentity Resource: "*" Environment: Variables: EXCLUDED_INSTANCES: "" 
Enter fullscreen mode Exit fullscreen mode

The above template defines our lambda function running on Python 3.13. It has been scheduled to run on a weekly basis on Monday 6 AM UTC and with a retry policy for the EventBridge schedule to retry the execution of our Lambda.

You can adjust the schedule of the runs according to your needs under the WeeklySchedule event

The IAM permissions attached to the lambda function include the following:

  • EC2 Describe – Allows the function to list running EC2 instances (ec2:DescribeInstances).
  • GuardDuty Malware Scan – Grants access to list detectors, get detector details, and start on-demand malware scans (guardduty:ListDetectors, guardduty:GetDetector, guardduty:StartMalwareScan).
  • IAM Role Access – Permits the function to read and pass the GuardDuty service-linked role required for malware protection (iam:GetRole, iam:PassRole).
  • STS Identity Check – Enables the function to retrieve its own AWS identity for logging and context (sts:GetCallerIdentity).

Now, we need to create a Python file to store our code that will launch the automated scans, by leveraging boto3 to interact with GuardDuty.

The file needs to be created under lambdas/infrastructure/ec2_malware_scan/ and should be named ec2_malware_scan.py as we defined it in the SAM template CodeUri: lambdas/infrastructure/ec2_malware_scan/

Below is the code needed in your Python script:

import boto3 import json import logging import os from typing import List, Dict, Any # Configure logging logger = logging.getLogger() logger.setLevel(logging.INFO) # Initialize AWS clients ec2 = boto3.client('ec2') guardduty = boto3.client('guardduty') sts = boto3.client('sts') def _get_detector_id() -> str: resp = guardduty.list_detectors() ids = resp.get('DetectorIds', []) if not ids: raise RuntimeError("No GuardDuty detectors found in this region") return ids[0] def _malware_protection_enabled() -> bool: detector_id = _get_detector_id() det = guardduty.get_detector(DetectorId=detector_id) ebs = ( det.get('DataSources', {}) .get('MalwareProtection', {}) .get('ScanEc2InstanceWithFindings', {}) .get('EbsVolumes', {}) ) enabled = ebs.get('Status') == 'ENABLED' if enabled: logger.info("GuardDuty Malware Protection (EBS) is ENABLED") else: logger.warning("GuardDuty Malware Protection (EBS) is DISABLED") return enabled def _running_instances() -> List[Dict[str, Any]]: account_id = sts.get_caller_identity()["Account"] region = ec2.meta.region_name paginator = ec2.get_paginator('describe_instances') pages = paginator.paginate( Filters=[ {'Name': 'instance-state-name', 'Values': ['running']}, {'Name': 'tag:Name', 'Values': ['*']} ] ) instances: List[Dict[str, Any]] = [] for page in pages: for r in page.get('Reservations', []): for inst in r.get('Instances', []): iid = inst['InstanceId'] instances.append({ 'InstanceId': iid, 'Arn': f"arn:aws:ec2:{region}:{account_id}:instance/{iid}", }) logger.info(f"Found {len(instances)} running EC2 instances") return instances def _start_scan(instance_arn: str) -> Dict[str, Any]: try: resp = guardduty.start_malware_scan(ResourceArn=instance_arn) return {'instance_arn': instance_arn, 'scan_id': resp['ScanId'], 'status': 'started'} except Exception as e: logger.error(f"Failed to start scan for {instance_arn}: {e}") return {'instance_arn': instance_arn, 'status': 'failed', 'error': str(e)} def ec2_malware_scan(event, context): logger.info("Weekly GuardDuty EC2 malware scan kickoff") if not _malware_protection_enabled(): return { 'statusCode': 400, 'body': json.dumps({ 'error': 'GuardDuty Malware Protection is not enabled', 'message': 'Enable Malware Protection (EBS volumes) in GuardDuty' }) } instances = _running_instances() # If you want to exclude instances from the scan, you can add their InstanceId to the EXCLUDED_INSTANCES environment variable excluded = {x.strip() for x in os.environ.get('EXCLUDED_INSTANCES', '').split(',') if x.strip()} to_scan = [i for i in instances if i['InstanceId'] not in excluded] logger.info(f"Excluded {len(instances) - len(to_scan)} instance(s); starting scans for {len(to_scan)}") results = [_start_scan(i['Arn']) for i in to_scan] return { 'statusCode': 200, 'body': json.dumps({ 'message': 'On-demand malware scans initiated', 'instances_scanned': len(results), 'instances_excluded': len(instances) - len(to_scan), 'results': results }) } 
Enter fullscreen mode Exit fullscreen mode

You can exclude instances from being scanned by adding their IDs to the EXCLUDED_INSTANCES environment variable.

Triggering the lambda function manually will run the scans, and you'll hopefully get no malware-infected instances :)

AWS EC2 Malware Scanning with GuardDuty

Thanks for tuning in!

Top comments (0)