Skip to content

Commit 33e424c

Browse files
authored
Capabilities to bootstrap Analysis VM in AWS (#76)
* Aws vm core (#74) * Add AWS VM bootstrapping capabilities Signed-off-by: Theo Giovanna <gtheo@google.com> * Address PR comments Signed-off-by: Theo Giovanna <gtheo@google.com> * Add unittests for AWS VM bootstrapping capabilities (#65) * Add unittests for AWS VM bootstrapping capabilities Signed-off-by: Theo Giovanna <gtheo@google.com> * Add assert_called statements in unittest Signed-off-by: Theo Giovanna <gtheo@google.com> * Add e2e tests for AWS VM bootstrapping capabilities (#66) * Add e2e tests for AWS VM bootstrapping capabilities Signed-off-by: Theo Giovanna <gtheo@google.com> * Seperate e2e vm and disk copy test Signed-off-by: Theo Giovanna <gtheo@google.com> * Replace loop with assertIn Signed-off-by: Theo Giovanna <gtheo@google.com>
1 parent 46a40e7 commit 33e424c

File tree

3 files changed

+463
-1
lines changed

3 files changed

+463
-1
lines changed

libcloudforensics/aws.py

Lines changed: 269 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import datetime
2323
import json
2424
import logging
25+
import os
2526
import re
2627

2728
import boto3
@@ -32,7 +33,10 @@
3233
EC2_SERVICE = 'ec2'
3334
ACCOUNT_SERVICE = 'sts'
3435
KMS_SERVICE = 'kms'
36+
# Default Amazon Machine Image to use for bootstrapping instances
37+
UBUNTU_1804_AMI = 'ami-0013b3aa57f8a4331'
3538
REGEX_TAG_VALUE = re.compile('^.{1,255}$')
39+
STARTUP_SCRIPT = 'scripts/startup.sh'
3640

3741

3842
class AWSAccount:
@@ -454,6 +458,84 @@ def CreateVolumeFromSnapshot(self,
454458
encrypted,
455459
name=volume_name)
456460

461+
def GetOrCreateAnalysisVm(self,
462+
vm_name,
463+
boot_volume_size,
464+
ami,
465+
cpu_cores,
466+
packages=None):
467+
"""Get or create a new virtual machine for analysis purposes.
468+
469+
Args:
470+
vm_name (str): The instance name tag of the virtual machine.
471+
boot_volume_size (int): The size of the analysis VM boot volume (in GB).
472+
ami (str): The Amazon Machine Image ID to use to create the VM.
473+
cpu_cores (int): Number of CPU cores for the analysis VM.
474+
packages (list(str)): Optional. List of packages to install in the VM.
475+
476+
Returns:
477+
tuple(AWSInstance, bool): A tuple with an AWSInstance object and a
478+
boolean indicating if the virtual machine was created (True) or
479+
reused (False).
480+
481+
Raises:
482+
RuntimeError: If the virtual machine cannot be found or created.
483+
"""
484+
485+
# Re-use instance if it already exists, or create a new one.
486+
try:
487+
instances = self.GetInstancesByName(vm_name)
488+
if instances:
489+
created = False
490+
return instances[0], created
491+
except RuntimeError:
492+
pass
493+
494+
instance_type = self._GetInstanceTypeByCPU(cpu_cores)
495+
startup_script = self._ReadStartupScript()
496+
if packages:
497+
startup_script = startup_script.replace('${packages[@]}', ' '.join(
498+
packages))
499+
500+
# Install ec2-instance-connect to allow SSH connections from the browser.
501+
startup_script = startup_script.replace(
502+
'(exit ${exit_code})',
503+
'apt -y install ec2-instance-connect && (exit ${exit_code})')
504+
505+
client = self.ClientApi(EC2_SERVICE)
506+
# Create the instance in AWS
507+
try:
508+
instance = client.run_instances(
509+
BlockDeviceMappings=[self._GetBootVolumeConfigByAmi(
510+
ami, boot_volume_size)],
511+
ImageId=ami,
512+
MinCount=1,
513+
MaxCount=1,
514+
InstanceType=instance_type,
515+
TagSpecifications=[GetTagForResourceType('instance', vm_name)],
516+
UserData=startup_script,
517+
Placement={'AvailabilityZone': self.default_availability_zone})
518+
519+
# If the call to run_instances was successful, then the API response
520+
# contains the instance ID for the new instance.
521+
instance_id = instance['Instances'][0]['InstanceId']
522+
523+
# Wait for the instance to be running
524+
client.get_waiter('instance_running').wait(InstanceIds=[instance_id])
525+
# Wait for the status checks to pass
526+
client.get_waiter('instance_status_ok').wait(InstanceIds=[instance_id])
527+
528+
instance = AWSInstance(self,
529+
instance_id,
530+
self.default_region,
531+
self.default_availability_zone,
532+
name=vm_name)
533+
created = True
534+
return instance, created
535+
except client.exceptions.ClientError as exception:
536+
raise RuntimeError('Could not create instance {0:s}: {1:s}'.format(
537+
vm_name, str(exception)))
538+
457539
def GetAccountInformation(self, info):
458540
"""Get information about the AWS account in use.
459541
@@ -591,6 +673,101 @@ def _GenerateVolumeName(self, snapshot, volume_name_prefix=None):
591673

592674
return volume_name
593675

676+
def _GetBootVolumeConfigByAmi(self, ami, boot_volume_size):
677+
"""Return a boot volume configuration for a given AMI and boot volume size.
678+
679+
Args:
680+
ami (str): The Amazon Machine Image ID.
681+
boot_volume_size (int): Size of the boot volume, in GB.
682+
683+
Returns:
684+
dict: A BlockDeviceMappings configuration for the specified AMI.
685+
686+
Raises:
687+
RuntimeError: If AMI details cannot be found.
688+
"""
689+
690+
client = self.ClientApi(EC2_SERVICE)
691+
try:
692+
image = client.describe_images(ImageIds=[ami])
693+
except client.exceptions.ClientError as exception:
694+
raise RuntimeError(
695+
'Could not find image information for AMI {0:s}: {1:s}'.format(
696+
ami, str(exception)))
697+
698+
# If the call to describe_images was successful, then the API's response
699+
# is expected to contain at least one image and its corresponding block
700+
# device mappings information.
701+
block_device_mapping = image['Images'][0]['BlockDeviceMappings'][0]
702+
block_device_mapping['Ebs']['VolumeSize'] = boot_volume_size
703+
return block_device_mapping
704+
705+
@staticmethod
706+
def _GetInstanceTypeByCPU(cpu_cores):
707+
"""Return the instance type for the requested number of CPU cores.
708+
709+
Args:
710+
cpu_cores (int): The number of requested cores.
711+
712+
Returns:
713+
str: The type of instance that matches the number of cores.
714+
715+
Raises:
716+
ValueError: If the requested amount of cores is unavailable.
717+
"""
718+
719+
cpu_cores_to_instance_type = {
720+
1: 't2.small',
721+
2: 'm4.large',
722+
4: 'm4.xlarge',
723+
8: 'm4.2xlarge',
724+
16: 'm4.4xlarge',
725+
32: 'm5.8xlarge',
726+
40: 'm4.10xlarge',
727+
48: 'm5.12xlarge',
728+
64: 'm4.16xlarge',
729+
96: 'm5.24xlarge',
730+
128: 'x1.32xlarge'
731+
}
732+
if cpu_cores not in cpu_cores_to_instance_type:
733+
raise ValueError(
734+
'Cannot start a machine with {0:d} CPU cores. CPU cores should be one'
735+
' of: {1:s}'.format(
736+
cpu_cores, ', '.join(map(str, cpu_cores_to_instance_type.keys()))
737+
))
738+
return cpu_cores_to_instance_type[cpu_cores]
739+
740+
@staticmethod
741+
def _ReadStartupScript():
742+
"""Read and return the startup script that is to be run on the forensics VM.
743+
744+
Users can either write their own script to install custom packages,
745+
or use the provided one. To use your own script, export a STARTUP_SCRIPT
746+
environment variable with the absolute path to it:
747+
"user@terminal:~$ export STARTUP_SCRIPT='absolute/path/script.sh'"
748+
749+
Returns:
750+
str: The script to run.
751+
752+
Raises:
753+
OSError: If the script cannot be opened, read or closed.
754+
"""
755+
756+
try:
757+
startup_script = os.environ.get('STARTUP_SCRIPT')
758+
if not startup_script:
759+
# Use the provided script
760+
startup_script = os.path.join(
761+
os.path.dirname(os.path.realpath(__file__)), STARTUP_SCRIPT)
762+
startup_script = open(startup_script)
763+
script = startup_script.read()
764+
startup_script.close()
765+
return script
766+
except OSError as exception:
767+
raise OSError(
768+
'Could not open/read/close the startup script {0:s}: {1:s}'.format(
769+
startup_script, str(exception)))
770+
594771

595772
class AWSInstance:
596773
"""Class representing an AWS EC2 instance.
@@ -649,6 +826,27 @@ def GetBootVolume(self):
649826
self.instance_id)
650827
raise RuntimeError(error_msg)
651828

829+
def GetVolume(self, volume_id):
830+
"""Get a volume attached to the instance by ID.
831+
832+
Args:
833+
volume_id (str): The ID of the volume to get.
834+
835+
Returns:
836+
AWSVolume: The AWSVolume object.
837+
838+
Raises:
839+
RuntimeError: If volume_id is not found amongst the volumes attached
840+
to the instance.
841+
"""
842+
843+
volume = self.ListVolumes().get(volume_id)
844+
if not volume:
845+
raise RuntimeError(
846+
'Volume {0:s} is not attached to instance {1:s}'.format(
847+
volume_id, self.instance_id))
848+
return volume
849+
652850
def ListVolumes(self):
653851
"""List all volumes for the instance.
654852
@@ -661,6 +859,28 @@ def ListVolumes(self):
661859
'Name': 'attachment.instance-id',
662860
'Values': [self.instance_id]}])
663861

862+
def AttachVolume(self, volume, device_name):
863+
"""Attach a volume to the AWS instance.
864+
865+
Args:
866+
volume (AWSVolume): The AWSVolume object to attach to the instance.
867+
device_name (str): The device name for the volume (e.g. /dev/sdf).
868+
869+
Raises:
870+
RuntimeError: If the volume could not be attached.
871+
"""
872+
873+
client = self.aws_account.ClientApi(EC2_SERVICE)
874+
try:
875+
client.attach_volume(
876+
Device=device_name,
877+
InstanceId=self.instance_id,
878+
VolumeId=volume.volume_id)
879+
volume.device_name = device_name
880+
except client.exceptions.ClientError as exception:
881+
raise RuntimeError('Could not attach volume {0:s}: {1:s}'.format(
882+
volume.volume_id, str(exception)))
883+
664884

665885
class AWSElasticBlockStore:
666886
"""Class representing an AWS EBS resource.
@@ -962,12 +1182,60 @@ def CreateVolumeCopy(zone,
9621182

9631183
except RuntimeError as exception:
9641184
error_msg = 'Copying volume {0:s}: {1!s}'.format(
965-
volume_id, exception)
1185+
(volume_id or instance_id), exception)
9661186
raise RuntimeError(error_msg)
9671187

9681188
return new_volume
9691189

9701190

1191+
def StartAnalysisVm(vm_name,
1192+
default_availability_zone,
1193+
boot_volume_size,
1194+
cpu_cores=4,
1195+
ami=UBUNTU_1804_AMI,
1196+
attach_volume=None,
1197+
device_name=None,
1198+
dst_account=None):
1199+
"""Start a virtual machine for analysis purposes.
1200+
1201+
Look for an existing AWS instance with tag name vm_name. If found,
1202+
this instance will be started and used as analysis VM. If not found, then a
1203+
new vm with that name will be created, started and returned.
1204+
1205+
Args:
1206+
vm_name (str): The name for the virtual machine.
1207+
default_availability_zone (str): Default zone within the region to create
1208+
new resources in.
1209+
boot_volume_size (int): The size of the analysis VM boot volume (in GB).
1210+
cpu_cores (int): Optional. The number of CPU cores to create the machine
1211+
with. Default is 4.
1212+
ami (str): Optional. The Amazon Machine Image ID to use to create the VM.
1213+
Default is a version of Ubuntu 18.04.
1214+
attach_volume (AWSVolume): Optional. The volume to attach.
1215+
device_name (str): Optional. The name of the device (e.g. /dev/sdf) for the
1216+
volume to be attached. Mandatory if attach_volume is provided.
1217+
dst_account (str): Optional. The AWS account in which to create the
1218+
analysis VM. This is the profile name that is defined in your AWS
1219+
credentials file.
1220+
1221+
Returns:
1222+
tuple(AWSInstance, bool): a tuple with a virtual machine object
1223+
and a boolean indicating if the virtual machine was created or not.
1224+
1225+
Raises:
1226+
RuntimeError: If device_name is missing when attach_volume is provided.
1227+
"""
1228+
aws_account = AWSAccount(default_availability_zone, aws_profile=dst_account)
1229+
analysis_vm, created = aws_account.GetOrCreateAnalysisVm(
1230+
vm_name, boot_volume_size, cpu_cores=cpu_cores, ami=ami)
1231+
if attach_volume:
1232+
if not device_name:
1233+
raise RuntimeError('If you want to attach a volume, you must also '
1234+
'specify a device name for that volume.')
1235+
analysis_vm.AttachVolume(attach_volume, device_name)
1236+
return analysis_vm, created
1237+
1238+
9711239
def GetTagForResourceType(resource, name):
9721240
"""Create a dictionary for AWS Tag Specifications.
9731241

tests/e2e/aws_e2e.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def setUpClass(cls):
5959
cls.zone = project_info['zone']
6060
cls.volume_to_forensic = project_info.get('volume_id', None)
6161
cls.aws = aws.AWSAccount(cls.zone)
62+
cls.analysis_vm_name = 'new-vm-for-analysis'
63+
cls.analysis_vm, _ = aws.StartAnalysisVm(
64+
cls.analysis_vm_name, cls.zone, 10, 4)
6265
cls.volumes = []
6366

6467
def test_end_to_end_boot_volume(self):
@@ -106,9 +109,45 @@ def test_end_to_end_other_volume(self):
106109
self.aws.ResourceApi(EC2_SERVICE).Volume(other_volume_copy.volume_id))
107110
self.assertEqual(self.volumes[-1].volume_id, other_volume_copy.volume_id)
108111

112+
def test_end_to_end_vm(self):
113+
"""End to end test on AWS.
114+
115+
This tests that an analysis VM is correctly created and that a volume
116+
passed to the attach_volume parameter is correctly attached.
117+
"""
118+
119+
volume_to_attach = aws.CreateVolumeCopy(
120+
self.zone,
121+
volume_id=self.volume_to_forensic)
122+
self.volumes.append(volume_to_attach)
123+
# Create and start the analysis VM and attach the boot volume
124+
self.analysis_vm, _ = aws.StartAnalysisVm(
125+
self.analysis_vm_name,
126+
self.zone,
127+
10,
128+
4,
129+
attach_volume=volume_to_attach,
130+
device_name='/dev/sdp'
131+
)
132+
133+
# The forensic instance should be live in the analysis AWS account and
134+
# the volume should be attached
135+
instance = self.aws.ResourceApi(EC2_SERVICE).Instance(
136+
self.analysis_vm.instance_id)
137+
self.assertEqual(instance.instance_id, self.analysis_vm.instance_id)
138+
self.assertIn(volume_to_attach.volume_id,
139+
[vol.volume_id for vol in instance.volumes.all()])
140+
109141
@classmethod
110142
def tearDownClass(cls):
111143
client = cls.aws.ClientApi(EC2_SERVICE)
144+
# Delete the instance
145+
instance = cls.aws.ResourceApi(EC2_SERVICE).Instance(
146+
cls.analysis_vm.instance_id)
147+
instance.terminate()
148+
client.get_waiter('instance_terminated').wait(InstanceIds=[
149+
instance.instance_id])
150+
112151
# Delete the volumes
113152
for volume in cls.volumes:
114153
log.info('Deleting volume: {0:s}.'.format(volume.volume_id))

0 commit comments

Comments
 (0)