22
22
import datetime
23
23
import json
24
24
import logging
25
+ import os
25
26
import re
26
27
27
28
import boto3
32
33
EC2_SERVICE = 'ec2'
33
34
ACCOUNT_SERVICE = 'sts'
34
35
KMS_SERVICE = 'kms'
36
+ # Default Amazon Machine Image to use for bootstrapping instances
37
+ UBUNTU_1804_AMI = 'ami-0013b3aa57f8a4331'
35
38
REGEX_TAG_VALUE = re .compile ('^.{1,255}$' )
39
+ STARTUP_SCRIPT = 'scripts/startup.sh'
36
40
37
41
38
42
class AWSAccount :
@@ -454,6 +458,84 @@ def CreateVolumeFromSnapshot(self,
454
458
encrypted ,
455
459
name = volume_name )
456
460
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
+
457
539
def GetAccountInformation (self , info ):
458
540
"""Get information about the AWS account in use.
459
541
@@ -591,6 +673,101 @@ def _GenerateVolumeName(self, snapshot, volume_name_prefix=None):
591
673
592
674
return volume_name
593
675
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
+
594
771
595
772
class AWSInstance :
596
773
"""Class representing an AWS EC2 instance.
@@ -649,6 +826,27 @@ def GetBootVolume(self):
649
826
self .instance_id )
650
827
raise RuntimeError (error_msg )
651
828
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
+
652
850
def ListVolumes (self ):
653
851
"""List all volumes for the instance.
654
852
@@ -661,6 +859,28 @@ def ListVolumes(self):
661
859
'Name' : 'attachment.instance-id' ,
662
860
'Values' : [self .instance_id ]}])
663
861
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
+
664
884
665
885
class AWSElasticBlockStore :
666
886
"""Class representing an AWS EBS resource.
@@ -962,12 +1182,60 @@ def CreateVolumeCopy(zone,
962
1182
963
1183
except RuntimeError as exception :
964
1184
error_msg = 'Copying volume {0:s}: {1!s}' .format (
965
- volume_id , exception )
1185
+ ( volume_id or instance_id ) , exception )
966
1186
raise RuntimeError (error_msg )
967
1187
968
1188
return new_volume
969
1189
970
1190
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
+
971
1239
def GetTagForResourceType (resource , name ):
972
1240
"""Create a dictionary for AWS Tag Specifications.
973
1241
0 commit comments