Skip to content

Commit 42d8faf

Browse files
authored
Merge pull request #252 from aquavis12/feat/ec2-security-keypair-imdsv2
Feat/ec2 security keypair imdsv2
2 parents 82b2828 + b3165da commit 42d8faf

5 files changed

Lines changed: 256 additions & 2 deletions

File tree

services/ec2/Ec2.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import time
1111

1212
from utils.Tools import _pi
13+
from utils.Tools import _warn
1314

1415
from utils.Config import Config
1516
from services.Service import Service
@@ -26,6 +27,8 @@
2627
from services.ec2.drivers.Ec2Vpc import Ec2Vpc
2728
from services.ec2.drivers.Ec2NACL import Ec2NACL
2829
from services.ec2.drivers.Ec2Regional import Ec2Regional
30+
from services.ec2.drivers.Ec2KeyPair import Ec2KeyPair
31+
from services.ec2.drivers.Ec2SSM import Ec2SSM
2932

3033
class Ec2(Service):
3134
CHARTSTYPE = {
@@ -385,9 +388,60 @@ def getNetworkACLs(self):
385388
result = self.ec2Client.describe_network_acls(
386389
NextToken = result.get('NextToken')
387390
)
388-
networkACLs = networkACLs + result.get('NetworkAcls')
391+
networkACLs = networkACLs + result.get('NetworkAcls')
389392
return networkACLs
390393

394+
def getKeyPairs(self):
395+
filters = []
396+
if self.tags:
397+
filters = self.tags
398+
399+
result = self.ec2Client.describe_key_pairs(
400+
Filters=filters
401+
)
402+
return result.get('KeyPairs', [])
403+
404+
def getInstanceKeyNames(self, instances):
405+
"""Collect all key names actively used by running/stopped instances"""
406+
keyNames = set()
407+
for instanceArr in instances:
408+
for instanceData in instanceArr['Instances']:
409+
keyName = instanceData.get('KeyName')
410+
if keyName:
411+
keyNames.add(keyName)
412+
return keyNames
413+
414+
def getSSMManagedInstances(self):
415+
"""Get set of instance IDs managed by SSM.
416+
417+
Returns a set of managed instance IDs on success, or None if the
418+
SSM API call fails (e.g. missing ssm:DescribeInstanceInformation
419+
permission, or SSM unavailable in the region). Returning None lets
420+
the caller skip the SSM check entirely instead of flagging every
421+
instance as unmanaged (false positives).
422+
"""
423+
managedSet = set()
424+
try:
425+
# MaxResults=50 is the API maximum — request full pages to
426+
# minimize round-trips for large fleets.
427+
results = self.ssmClient.describe_instance_information(MaxResults=50)
428+
for info in results.get('InstanceInformationList', []):
429+
managedSet.add(info['InstanceId'])
430+
431+
while results.get('NextToken'):
432+
results = self.ssmClient.describe_instance_information(
433+
MaxResults=50,
434+
NextToken=results['NextToken']
435+
)
436+
for info in results.get('InstanceInformationList', []):
437+
managedSet.add(info['InstanceId'])
438+
except botocore.exceptions.ClientError as e:
439+
_warn("Unable to retrieve SSM managed instances ({}); skipping SSM check to avoid false positives.".format(e.response['Error']['Code']))
440+
return None
441+
except Exception as e:
442+
_warn("Unable to retrieve SSM managed instances ({}); skipping SSM check to avoid false positives.".format(e))
443+
return None
444+
return managedSet
391445

392446
def getChartGenCost(self):
393447
'''
@@ -665,6 +719,29 @@ def advise(self):
665719
objs[f"NACL::{nacl['NetworkAclId']}"] = obj.getInfo()
666720

667721

722+
# Key Pair Checks
723+
keyPairs = self.getKeyPairs()
724+
instanceKeyNames = self.getInstanceKeyNames(instances)
725+
for kp in keyPairs:
726+
_pi('EC2::Key Pair', kp['KeyName'])
727+
obj = Ec2KeyPair(kp, instanceKeyNames)
728+
obj.run(self.__class__)
729+
objs[f"KeyPair::{kp['KeyName']}"] = obj.getInfo()
730+
731+
# SSM Managed Instance Checks
732+
ssmManagedSet = self.getSSMManagedInstances()
733+
# Skip the SSM check entirely if the managed-instance lookup failed
734+
# (getSSMManagedInstances returns None) to avoid false positives.
735+
if ssmManagedSet is not None:
736+
for instanceArr in instances:
737+
for instanceData in instanceArr['Instances']:
738+
if instanceData['State']['Name'] not in ('running', 'stopped'):
739+
continue
740+
_pi('EC2::SSM Check', instanceData['InstanceId'])
741+
obj = Ec2SSM(instanceData['InstanceId'], ssmManagedSet)
742+
obj.run(self.__class__)
743+
objs[f"SSM::{instanceData['InstanceId']}"] = obj.getInfo()
744+
668745
if self.getChartGenCost():
669746
self.setChartData({"EC2 Instance Family Pricing": self.getChartGenCost()})
670747

services/ec2/drivers/Ec2Instance.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import boto3
22
import botocore
33
import datetime
4+
import re
45
import time
56
from utils.Config import Config
67

@@ -423,7 +424,73 @@ def _checkEC2HasTag(self):
423424
if self.ec2InstanceData.get('Tags') is None:
424425
self.results['EC2HasTag'] = [-1, '']
425426
return
426-
427+
428+
def _checkIMDSv2(self):
429+
"""Check if instance enforces IMDSv2 (HttpTokens = required)"""
430+
instance = self.ec2InstanceData
431+
metadataOptions = instance.get('MetadataOptions', {})
432+
httpTokens = metadataOptions.get('HttpTokens', 'optional')
433+
434+
if httpTokens != 'required':
435+
self.results['EC2IMDSv2'] = [-1, 'Not enforced']
436+
return
437+
438+
def _checkStoppedTooLong(self):
439+
"""Flag instances stopped for more than 30 days (still incurring EBS cost)"""
440+
instance = self.ec2InstanceData
441+
if instance['State']['Name'] != 'stopped':
442+
return
443+
444+
reason = instance.get('StateTransitionReason', '')
445+
# Format: "User initiated (2024-01-15 10:30:00 GMT)"
446+
if '(' in reason and ')' in reason:
447+
match = re.search(r'\((\d{4}-\d{2}-\d{2})', reason)
448+
if match:
449+
stopped_date = datetime.datetime.strptime(match.group(1), '%Y-%m-%d').date()
450+
days_stopped = (datetime.date.today() - stopped_date).days
451+
if days_stopped > 30:
452+
self.results['EC2StoppedTooLong'] = [-1, f'{days_stopped} days']
453+
return
454+
455+
def _checkTerminationProtection(self):
456+
"""Check if instance has termination protection enabled.
457+
458+
Stopped instances are intentionally included: they can be critical
459+
(e.g. reserved for DR failover) and still benefit from deletion
460+
protection. Only terminated/shutting-down instances are skipped.
461+
462+
This uses describe_instance_attribute, which has no batch form and
463+
is therefore called once per instance. The shared boto client is
464+
configured with standard retry mode (max_attempts=5), so transient
465+
throttling is retried automatically by botocore. If throttling still
466+
surfaces after retries, the check is skipped (with a warning) rather
467+
than emitting a misleading result.
468+
"""
469+
instance = self.ec2InstanceData
470+
471+
# Skip terminated/shutting-down instances (nothing left to protect)
472+
if instance['State']['Name'] in ('terminated', 'shutting-down'):
473+
return
474+
475+
try:
476+
resp = self.ec2Client.describe_instance_attribute(
477+
InstanceId=instance['InstanceId'],
478+
Attribute='disableApiTermination'
479+
)
480+
protected = resp.get('DisableApiTermination', {}).get('Value', False)
481+
if not protected:
482+
self.results['EC2NoTerminationProtection'] = [-1, 'Disabled']
483+
except botocore.exceptions.ClientError as e:
484+
code = e.response['Error']['Code']
485+
if code in ('RequestLimitExceeded', 'Throttling', 'ThrottlingException'):
486+
_warn("Throttled while checking termination protection for {}; skipping.".format(instance['InstanceId']), forcePrint=False)
487+
# Other client errors (e.g. permission) are skipped silently to
488+
# avoid emitting a false 'Disabled' result.
489+
return
490+
except Exception:
491+
return
492+
return
493+
427494
def checkInstanceTypeAvailable(self, instanceType):
428495
resp = self.ec2Client.describe_instance_type_offerings(
429496
LocationType='region',

services/ec2/drivers/Ec2KeyPair.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import botocore
2+
3+
from services.Evaluator import Evaluator
4+
5+
class Ec2KeyPair(Evaluator):
6+
"""Check for EC2 key pairs that are not associated with any running instance."""
7+
8+
def __init__(self, keyPair, instanceKeyNames):
9+
super().__init__()
10+
self.keyPair = keyPair
11+
self.instanceKeyNames = instanceKeyNames
12+
13+
self._resourceName = keyPair['KeyName']
14+
15+
self.init()
16+
17+
def _checkKeyPairInUse(self):
18+
"""Flag key pairs not attached to any running instance"""
19+
keyName = self.keyPair['KeyName']
20+
if keyName not in self.instanceKeyNames:
21+
self.results['EC2KeyPairNotInUse'] = [-1, keyName]

services/ec2/drivers/Ec2SSM.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import botocore
2+
3+
from services.Evaluator import Evaluator
4+
5+
class Ec2SSM(Evaluator):
6+
"""Check if an EC2 instance is managed by AWS Systems Manager."""
7+
8+
def __init__(self, instanceId, ssmManagedSet):
9+
super().__init__()
10+
self.instanceId = instanceId
11+
self.ssmManagedSet = ssmManagedSet
12+
13+
self._resourceName = instanceId
14+
15+
self.init()
16+
17+
def _checkSSMManaged(self):
18+
"""Flag instances not registered with SSM (no patching/compliance visibility)"""
19+
if self.instanceId not in self.ssmManagedSet:
20+
self.results['EC2SSMNotManaged'] = [-1, 'Not managed']

services/ec2/ec2.reporter.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,75 @@
5151
"[Elastic IP Charges]<https://aws.amazon.com/premiumsupport/knowledge-center/elastic-ip-charges/>"
5252
]
5353
},
54+
"EC2KeyPairNotInUse": {
55+
"category": "S",
56+
"^description": "Unused Key Pairs: {$COUNT} of your EC2 key pairs are not associated with any running instance. Remove unused key pairs to reduce the attack surface and improve security hygiene.",
57+
"downtime": 0,
58+
"slowness": 0,
59+
"additionalCost": 0,
60+
"criticality": "L",
61+
"needFullTest": 0,
62+
"shortDesc": "Remove Unused Key Pairs",
63+
"ref": [
64+
"[Amazon EC2 key pairs]<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html>",
65+
"[Security best practices]<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-security.html>"
66+
]
67+
},
68+
"EC2IMDSv2": {
69+
"category": "S",
70+
"^description": "IMDSv2 Not Enforced: {$COUNT} of your instances have not enforced IMDSv2 (Instance Metadata Service v2). IMDSv2 adds session-based authentication and mitigates SSRF attacks. Set HttpTokens to 'required' to enforce IMDSv2.",
71+
"downtime": 0,
72+
"slowness": 0,
73+
"additionalCost": 0,
74+
"criticality": "H",
75+
"needFullTest": 1,
76+
"shortDesc": "Enforce IMDSv2",
77+
"ref": [
78+
"[Configure IMDSv2]<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html>",
79+
"[IMDSv2 Best Practices]<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-metadata-transition-to-version-2.html>"
80+
]
81+
},
82+
"EC2StoppedTooLong": {
83+
"category": "C",
84+
"^description": "Stopped Instances (>30 days): {$COUNT} of your instances have been in a stopped state for more than 30 days. Stopped instances still incur EBS storage costs. Consider creating an AMI and terminating the instance, or terminate if no longer needed.",
85+
"downtime": 0,
86+
"slowness": 0,
87+
"additionalCost": 0,
88+
"criticality": "M",
89+
"needFullTest": 0,
90+
"shortDesc": "Terminate Long-Stopped Instances",
91+
"ref": [
92+
"[Instance lifecycle]<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-lifecycle.html>",
93+
"[Stop and start instances]<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Stop_Start.html>"
94+
]
95+
},
96+
"EC2NoTerminationProtection": {
97+
"category": "R",
98+
"^description": "No Termination Protection: {$COUNT} of your instances do not have termination protection enabled. Enable termination protection to prevent accidental deletion of critical instances.",
99+
"downtime": 0,
100+
"slowness": 0,
101+
"additionalCost": 0,
102+
"criticality": "M",
103+
"needFullTest": 0,
104+
"shortDesc": "Enable Termination Protection",
105+
"ref": [
106+
"[Enable termination protection]<https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_ChangingDisableAPITermination.html>"
107+
]
108+
},
109+
"EC2SSMNotManaged": {
110+
"category": "O",
111+
"^description": "SSM Not Managed: {$COUNT} of your instances are not managed by AWS Systems Manager. SSM provides patching, compliance visibility, remote access, and inventory management. Install the SSM agent and attach the required IAM role.",
112+
"downtime": 0,
113+
"slowness": 0,
114+
"additionalCost": 0,
115+
"criticality": "M",
116+
"needFullTest": 0,
117+
"shortDesc": "Register with SSM",
118+
"ref": [
119+
"[Setting up Systems Manager]<https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-setting-up.html>",
120+
"[SSM Agent]<https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-agent.html>"
121+
]
122+
},
54123
"EC2MemoryMonitor": {
55124
"category": "P",
56125
"^description": "EC2 Memory Monitoring: Memory monitoring has not been enabled for {$COUNT} of your instances. Install CloudWatch agent and enable the monitoring",

0 commit comments

Comments
 (0)