diff --git a/README.md b/README.md index 46528b8..a3c876f 100644 --- a/README.md +++ b/README.md @@ -65,3 +65,105 @@ Required parameters: - `Data` - Data such as when the value of Type is HEADER , enter the name of the header that you want AWS WAF to search, for example, User-Agent or Referer - `Transform` - Text transformations eliminate some of the unusual formatting that attackers use in web requests in an effort to bypass AWS WAF. Implementation require to be serialised with other waf condition. +### AmazonMQ Broker + +This custom resource creates a AmazonMQ broker instance. + +**NOTE:** This resource cannot be updated. If a change to the instance is required such as Instance Type, a new broker resource must be created. + +handler: `amazon-mq-broker/handler.lambda_handler` +runtime: `python3.6` + +Required parameters: + +- `Name` - Unique name given to the broker +- `SecurityGroups` - Array of security group ids +- `Subnets` - Array of subnets ids +- `MultiAZ` - String boolean [ 'true', 'false' ] +- `InstanceType` - valid values [ 'mq.t2.micro', 'mq.m4.large' ] +- `Username` - Username for the amq user +- `Password` - Password for the amq user. Must be 12-250 characters long + +No optional parameters. + +Returned Values: + +- `Active` - Active AmazonMQ endpoint +- `Stanby` - Standby AmazonMQ endpoint +- `BrokerId` - Id of the AmazonMQ Broker +- `BrokerArn` - Arn of the broker + +IAM Permissions: + +```json +{ + "Statement": + [ + { + "Effect": "Allow", + "Action": + [ + "mq:*", + "ec2:CreateNetworkInterface", + "ec2:CreateNetworkInterfacePermission", + "ec2:DeleteNetworkInterface", + "ec2:DeleteNetworkInterfacePermission", + "ec2:DetachNetworkInterface", + "ec2:DescribeInternetGateways", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeNetworkInterfacePermissions", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcs", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "lambda:InvokeFunction" + ], + "Resource": ["*"] + } + ] +} +``` + +### Auto generated secure ssm parameters + +This custom resource generates a random string `[a-z][A-Z][0-9]` a definable length. The string is then return to the cfn stack and can then be passed into other resources requiring a password. The resource can be updated generating a new password and updating the SSM parameter and returning the new password by passing a dummy parameter into the custom resource. + +handler: `ssm-secure-parameter/handler.lambda_handler` +runtime: `python3.6` + +Required parameters: + +- `Path` - SSM parameter path e.g. `/app/env/password` + +Optional parameters: + +- `Length` - Length of the auto generated password. Defaults to 16 characters + +Returned Values: + +- `Password` - The password generated by the resource + +IAM Permissions: + +```json +{ + "Statement": + [ + { + "Effect": "Allow", + "Action": + [ + "ssm:PutParameter", + "ssm:DeleteParameter", + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": ["*"] + } + ] +} +``` diff --git a/amazon-mq-broker/__init__.py b/amazon-mq-broker/__init__.py new file mode 100644 index 0000000..323c228 --- /dev/null +++ b/amazon-mq-broker/__init__.py @@ -0,0 +1 @@ +# package marker diff --git a/amazon-mq-broker/cr_response.py b/amazon-mq-broker/cr_response.py new file mode 100644 index 0000000..19fd431 --- /dev/null +++ b/amazon-mq-broker/cr_response.py @@ -0,0 +1,58 @@ +import logging +from urllib.request import urlopen, Request, HTTPError, URLError +import json + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +class CustomResourceResponse: + def __init__(self, request_payload): + self.payload = request_payload + self.response = { + "StackId": request_payload["StackId"], + "RequestId": request_payload["RequestId"], + "LogicalResourceId": request_payload["LogicalResourceId"], + "Status": 'SUCCESS', + } + + def respond_error(self, message): + self.response['Status'] = 'FAILED' + self.response['Reason'] = message + self.respond() + + def respond(self, data=None): + event = self.payload + response = self.response + #### + #### copied from https://github.com/ryansb/cfn-wrapper-python/blob/master/cfn_resource.py + #### + + if event.get("PhysicalResourceId", False): + response["PhysicalResourceId"] = event["PhysicalResourceId"] + + if data is not None: + response['Data'] = data + + logger.debug("Received %s request with event: %s" % (event['RequestType'], json.dumps(event))) + + serialized = json.dumps(response) + logger.info(f"Responding to {event['RequestType']} request with: {serialized}") + + req_data = serialized.encode('utf-8') + + req = Request( + event['ResponseURL'], + data=req_data, + headers={'Content-Length': len(req_data),'Content-Type': ''} + ) + req.get_method = lambda: 'PUT' + + try: + urlopen(req) + logger.debug("Request to CFN API succeeded, nothing to do here") + except HTTPError as e: + logger.error("Callback to CFN API failed with status %d" % e.code) + logger.error("Response: %s" % e.reason) + except URLError as e: + logger.error("Failed to reach the server - %s" % e.reason) diff --git a/amazon-mq-broker/handler.py b/amazon-mq-broker/handler.py new file mode 100644 index 0000000..bc4c84f --- /dev/null +++ b/amazon-mq-broker/handler.py @@ -0,0 +1,71 @@ +import sys +import os +import json + +sys.path.append(f"{os.environ['LAMBDA_TASK_ROOT']}/lib") +sys.path.append(os.path.dirname(os.path.realpath(__file__))) + +import cr_response +import logic +import lambda_invoker + +def lambda_handler(event, context): + + print(f"Received event:{json.dumps(event)}") + + lambda_response = cr_response.CustomResourceResponse(event) + cr_params = event['ResourceProperties'] + + # Validate input + for key in ['MultiAZ', 'InstanceType', 'Username', 'Password', 'SecurityGroups', 'Subnets']: + if key not in cr_params: + lambda_response.respond_error(f"{key} property missing") + return + + try: + broker = logic.AmazonMQBrokerLogic(cr_params['Name']) + if event['RequestType'] == 'Create': + if ('WaitComplete' in event) and (event['WaitComplete']): + result = broker.wait_broker_status(event['PhysicalResourceId'], context) + + if result is None: + invoke = lambda_invoker.LambdaInvoker() + invoke.invoke(event) + elif result: + lambda_response.respond(data=event['Data']) + elif not result: + lambda_response.respond_error(f"Creation of AmazonMQ {event['PhysicalResourceId']} failed.") + + else: + response = broker.create( + multi_az=cr_params['MultiAZ'], + instance_type=cr_params['InstanceType'], + user=cr_params['Username'], + password=cr_params['Password'], + security_groups=cr_params['SecurityGroups'], + subnets=cr_params['Subnets'] + ) + + event['PhysicalResourceId'] = response['BrokerId'] + event['Data'] = response + event['WaitComplete'] = True + invoke = lambda_invoker.LambdaInvoker() + invoke.invoke(event) + + elif event['RequestType'] == 'Update': + comparision = broker.compare_broker_properites(event['PhysicalResourceId'], event['ResourceProperties']) + if not comparision: + lambda_response.respond_error("AmazonMQ resource cannot be updated. Create a new resource if changes are required.") + else: + response = broker.get_broker_data(event['PhysicalResourceId'], event['ResourceProperties']['MultiAZ']) + lambda_response.respond(data=response) + + elif event['RequestType'] == 'Delete': + broker.delete(event['PhysicalResourceId']) + lambda_response.respond() + + except Exception as e: + message = str(e) + lambda_response.respond_error(message) + + return 'OK' diff --git a/amazon-mq-broker/lambda_invoker.py b/amazon-mq-broker/lambda_invoker.py new file mode 100644 index 0000000..1fdf724 --- /dev/null +++ b/amazon-mq-broker/lambda_invoker.py @@ -0,0 +1,20 @@ +import boto3 +import os +import json + + +class LambdaInvoker: + def __init__(self): + print(f"Initialize lambda invoker") + + def invoke(self, payload): + bytes_payload = bytearray() + bytes_payload.extend(map(ord, json.dumps(payload))) + function_name = os.environ['AWS_LAMBDA_FUNCTION_NAME'] + function_payload = bytes_payload + client = boto3.client('lambda') + client.invoke( + FunctionName=function_name, + InvocationType='Event', + Payload=function_payload + ) diff --git a/amazon-mq-broker/logic.py b/amazon-mq-broker/logic.py new file mode 100644 index 0000000..824930c --- /dev/null +++ b/amazon-mq-broker/logic.py @@ -0,0 +1,103 @@ +import boto3 +import os +import time + +class AmazonMQBrokerLogic: + + def __init__(self, broker_name): + self.broker_name = broker_name + + def create(self, multi_az, instance_type, user, password, security_groups, subnets): + print(f"Creating AMQ instance {self.broker_name}") + deployment_mode = "ACTIVE_STANDBY_MULTI_AZ" if multi_az.lower() == "true" else "SINGLE_INSTANCE" + + client = boto3.client('mq') + response = client.create_broker( + AutoMinorVersionUpgrade=False, + BrokerName=self.broker_name, + DeploymentMode=deployment_mode, + EngineType='ACTIVEMQ', + EngineVersion='5.15.0', + HostInstanceType=instance_type, + PubliclyAccessible=False, + SecurityGroups=security_groups, + SubnetIds=subnets, + Users=[ + { + 'ConsoleAccess': True, + 'Password': password, + 'Username': user + } + ] + ) + print(f"Broker Id: {response['BrokerId']} Broker Arn: {response['BrokerArn']}") + active = self.endpoint(response['BrokerId'],1) + response.update({'Active': active}) + + standby = self.endpoint(response['BrokerId'],2) if multi_az.lower() == "true" else "NONE" + response.update({'Standby': standby}) + + print(f"Creating Amazon MQ instance\n{response}") + return response + + def wait_broker_status(self, id, lambda_context): + client = boto3.client('mq') + + while True: + response = client.describe_broker(BrokerId=id) + state = response['BrokerState'] + + print(f"Broker state: {state}") + if state == 'RUNNING': + print(f"Matched {state} - OK ") + return True + elif state == 'CREATION_FAILED': + print(f"Matched {state} - ERROR ") + return False + elif lambda_context.get_remaining_time_in_millis() < 10000: + print(f"Less than 10 seconds left of Lambda execution time, exiting with empty hands") + return None + else: + print(f"Waiting for 5 seconds, time remaining" + + f"in this lambda execution {lambda_context.get_remaining_time_in_millis()}ms") + time.sleep(5) + + def compare_broker_properites(self, id, properties): + client = boto3.client('mq') + response = client.describe_broker(BrokerId=id) + + deployment_mode = "ACTIVE_STANDBY_MULTI_AZ" if properties['MultiAZ'].lower() == "true" else "SINGLE_INSTANCE" + + if (properties['SecurityGroups'] == response['SecurityGroups']) and \ + (properties['Subnets'] == response['SubnetIds']) and \ + (properties['InstanceType'] == response['HostInstanceType']) and \ + (deployment_mode == response['DeploymentMode']) and \ + (properties['Name'] == response['BrokerName']): + return True + else: + return False + + def get_broker_data(self, id, multi_az): + data = {} + client = boto3.client('mq') + response = client.describe_broker(BrokerId=id) + + data.update({'BrokerId': response['BrokerId']}) + data.update({'BrokerArn': response['BrokerArn']}) + + active = self.endpoint(response['BrokerId'],1) + data.update({'Active': active}) + + standby = self.endpoint(response['BrokerId'],2) if multi_az.lower() == "true" else "NONE" + data.update({'Standby': standby}) + + return data + + def delete(self,id): + client = boto3.client('mq') + client.delete_broker( + BrokerId=id + ) + + def endpoint(self,id,n): + return f"{id}-{n}.mq.{os.environ['AWS_REGION']}.amazonaws.com" diff --git a/ssm-secure-parameter/__init__.py b/ssm-secure-parameter/__init__.py new file mode 100644 index 0000000..323c228 --- /dev/null +++ b/ssm-secure-parameter/__init__.py @@ -0,0 +1 @@ +# package marker diff --git a/ssm-secure-parameter/cr_response.py b/ssm-secure-parameter/cr_response.py new file mode 100644 index 0000000..16ded13 --- /dev/null +++ b/ssm-secure-parameter/cr_response.py @@ -0,0 +1,62 @@ +import logging +from urllib.request import urlopen, Request, HTTPError, URLError +import json + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +class CustomResourceResponse: + def __init__(self, request_payload): + self.payload = request_payload + self.response = { + "StackId": request_payload["StackId"], + "RequestId": request_payload["RequestId"], + "LogicalResourceId": request_payload["LogicalResourceId"], + "NoEcho": True, + "Status": 'SUCCESS', + } + + def respond_error(self, message): + self.response['Status'] = 'FAILED' + self.response['Reason'] = message + self.respond() + + def respond(self, data=None, NoEcho=False): + event = self.payload + response = self.response + #### + #### copied from https://github.com/ryansb/cfn-wrapper-python/blob/master/cfn_resource.py + #### + + if event.get("PhysicalResourceId", False): + response["PhysicalResourceId"] = event["PhysicalResourceId"] + + if data is not None: + response['Data'] = data + + logger.debug("Received %s request with event: %s" % (event['RequestType'], json.dumps(event))) + + if NoEcho: + response['NoEcho'] = NoEcho + + serialized = json.dumps(response) + logger.info(f"Responding to {event['RequestType']} request with: {serialized}") + + req_data = serialized.encode('utf-8') + + req = Request( + event['ResponseURL'], + data=req_data, + headers={'Content-Length': len(req_data),'Content-Type': ''} + ) + req.get_method = lambda: 'PUT' + + try: + urlopen(req) + logger.debug("Request to CFN API succeeded, nothing to do here") + except HTTPError as e: + logger.error("Callback to CFN API failed with status %d" % e.code) + logger.error("Response: %s" % e.reason) + except URLError as e: + logger.error("Failed to reach the server - %s" % e.reason) diff --git a/ssm-secure-parameter/handler.py b/ssm-secure-parameter/handler.py new file mode 100644 index 0000000..ba21b42 --- /dev/null +++ b/ssm-secure-parameter/handler.py @@ -0,0 +1,60 @@ +import sys +import os + +sys.path.append(f"{os.environ['LAMBDA_TASK_ROOT']}/lib") +sys.path.append(os.path.dirname(os.path.realpath(__file__))) + +import cr_response +import logic +import json + +def lambda_handler(event, context): + + print(f"Received event:{json.dumps(event)}") + + lambda_response = cr_response.CustomResourceResponse(event) + cr_params = event['ResourceProperties'] + print(f"Resource Properties {cr_params}") + # Validate input + for key in ['Path']: + if key not in cr_params: + lambda_response.respond_error(f"{key} property missing") + return + + try: + parameter = logic.SSMSecureParameterLogic(cr_params['Path']) + length = 16 or cr_params['Length'] + + if event['RequestType'] == 'Create': + password, version = parameter.create( + length=length, + update=False + ) + + event['PhysicalResourceId'] = cr_params['Path'] + lambda_response.respond(data={ + "Password": password, + "Version": version + }) + + elif event['RequestType'] == 'Update': + password, version = parameter.create( + length=length, + update=True + ) + + event['PhysicalResourceId'] = cr_params['Path'] + lambda_response.respond(data={ + "Password": password, + "Version": version + }) + + elif event['RequestType'] == 'Delete': + parameter.delete() + lambda_response.respond() + + except Exception as e: + message = str(e) + lambda_response.respond_error(message) + + return 'OK' diff --git a/ssm-secure-parameter/logic.py b/ssm-secure-parameter/logic.py new file mode 100644 index 0000000..be6dc35 --- /dev/null +++ b/ssm-secure-parameter/logic.py @@ -0,0 +1,29 @@ +import boto3 +import string +import random + +class SSMSecureParameterLogic: + + def __init__(self, path): + self.path = path + + def create(self, length, update): + password = self.generate_password(length) + client = boto3.client('ssm') + response = client.put_parameter( + Name=self.path, + Value=password, + Overwrite=update, + Type='SecureString' + ) + return password, response['Version'] + + def delete(self): + client = boto3.client('ssm') + client.delete_parameter( + Name=self.path + ) + + def generate_password(self, length): + print(f"Generating a new password {length} chars long") + return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length))