From b78e69cde38c24526976c45716b2c4ac19cc29ae Mon Sep 17 00:00:00 2001 From: Scott Kellish Date: Fri, 11 Feb 2022 23:20:28 -0500 Subject: [PATCH] First commit after repackage and removal of build/ which included AWS account number --- hooks/python-hooks/ec2-ssm-sm-only/.gitignore | 131 +++++++++ .../python-hooks/ec2-ssm-sm-only/.rpdk-config | 21 ++ hooks/python-hooks/ec2-ssm-sm-only/README.md | 80 ++++++ ...mples-ec2ssmsmonly-hook-configuration.json | 16 ++ .../awssamples-ec2ssmsmonly-hook.json | 50 ++++ .../ec2-ssm-sm-only/hook-role.yaml | 44 +++ .../ec2-ssm-sm-only/requirements.txt | 1 + .../ec2-ssm-sm-only/resource-role.yaml | 32 +++ .../awssamples_ec2ssmsmonly_hook/__init__.py | 0 .../awssamples_ec2ssmsmonly_hook/handlers.py | 243 +++++++++++++++++ .../awssamples_ec2ssmsmonly_hook/models.py | 54 ++++ .../python-hooks/ec2-ssm-sm-only/template.yml | 24 ++ .../ec2-ssm-sm-only/tests/use-cases.yml | 252 ++++++++++++++++++ 13 files changed, 948 insertions(+) create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/.gitignore create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/.rpdk-config create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/README.md create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook-configuration.json create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook.json create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/hook-role.yaml create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/requirements.txt create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/resource-role.yaml create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/__init__.py create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/handlers.py create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/models.py create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/template.yml create mode 100644 hooks/python-hooks/ec2-ssm-sm-only/tests/use-cases.yml diff --git a/hooks/python-hooks/ec2-ssm-sm-only/.gitignore b/hooks/python-hooks/ec2-ssm-sm-only/.gitignore new file mode 100644 index 0000000..e3885ad --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/.gitignore @@ -0,0 +1,131 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# contains credentials +sam-tests/ + +rpdk.log* + +.DS_Store diff --git a/hooks/python-hooks/ec2-ssm-sm-only/.rpdk-config b/hooks/python-hooks/ec2-ssm-sm-only/.rpdk-config new file mode 100644 index 0000000..01f5b5e --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/.rpdk-config @@ -0,0 +1,21 @@ +{ + "artifact_type": "HOOK", + "typeName": "AWSSamples::Ec2SsmSmOnly::Hook", + "language": "python36", + "runtime": "python3.6", + "entrypoint": "awssamples_ec2ssmsmonly_hook.handlers.hook", + "testEntrypoint": "awssamples_ec2ssmsmonly_hook.handlers.test_entrypoint", + "settings": { + "version": false, + "subparser_name": null, + "verbose": 0, + "force": false, + "type_name": null, + "artifact_type": null, + "endpoint_url": null, + "region": null, + "target_schemas": [], + "use_docker": true, + "protocolVersion": "2.0.0" + } +} diff --git a/hooks/python-hooks/ec2-ssm-sm-only/README.md b/hooks/python-hooks/ec2-ssm-sm-only/README.md new file mode 100644 index 0000000..299d4a9 --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/README.md @@ -0,0 +1,80 @@ +# AWSSamples::Ec2SsmSmOnly::Hook + +This AWS CloudFormation Hook validates that an EC2 instance to be deployed, can only be accessed using [AWS Systems Manager Session Manager](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager.html). + +The Hook currently checks [AWS::EC2::Instance](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html) and [AWS::EC2::LaunchTemplate](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ec2-launchtemplate.html) resource types. Instances deploed via [AWS::AutoScaling::LaunchConfiguration](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-autoscaling-launchconfiguration.html) are not currently checked. + +### Validation Overview ### +The validation consists of the following high-level steps: +1. Ensure the instance has a [IamInstanceProfile](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html#cfn-ec2-instance-iaminstanceprofile) assigned +2. Simulate the [IamInstanceProfile](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html#cfn-ec2-instance-iaminstanceprofile) role to ensure it 'Allows' the following permissions: + * `ssmmessages:CreateControlChannel` + * `ssmmessages:CreateDataChannel` + * `ssmmessages:OpenControlChannel` + * `ssmmessages:OpenDataChannel` + +3. Verify none of the instance security groups allow ingress on 22/SSH if a Linux instance or 3389/RDP if a Windows instance. + + Security Groups are checked depending on how specified for the instance: + * `SecurityGroupIds` property (non-default VPC) + * `SecurityGroups` property (EC2-Classic, default VPC) + * `NetworkInterfaces` property + +### Requiring KMS Encrypted SSM Sessions +The hook can enforce KMS key encryption of SSM Session data by requiring the instance [IamInstanceProfile](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html#cfn-ec2-instance-iaminstanceprofile) role to include the `kms:decrypt` permission. You can control this by setting the `requireSessionManagerEncryption` property in the Hook Configuration JSON as shown below. + +
+{
+    "CloudFormationConfiguration": {
+        "HookConfiguration": {
+            "TargetStacks": "ALL",
+            "FailureMode": "FAIL",
+            "Properties": {
+                "requireSessionManagerEncryption": true
+            }
+        }
+    }
+}
+
+See [Defining the account-level configuration of an extension](https://docs.aws.amazon.com/cloudformation-cli/latest/userguide/resource-type-model.html#resource-type-howto-configuration) in the *CloudFormation CLI User Guide*. + +### Testing + +An AWS CloudFormation template is provided in the `testing` folder to exercise various failure use-cases by manipulating the provided template parameters: + +* `IncludeInstanceProfile: (True|False)` + + Set to `False` to remove the [IamInstanceProfile](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html#cfn-ec2-instance-iaminstanceprofile) property which should result in an `IamInstanceProfile property missing or empty value` error + +* `ManagedOrManualIAMPolicy (Managed|Manual)` + + Set to `Managed` to generate the [IamInstanceProfile](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html#cfn-ec2-instance-iaminstanceprofile) role policy using the `arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore` IAM Managed policy. + + Set to `Manual` to generate the [IamInstanceProfile](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html#cfn-ec2-instance-iaminstanceprofile) role policy using an inline policy containing the required SSM Session Manager permissions +* `TestIllegalManualSSMPolicies (True|False)` + + Set to `True` to omit the `ssmmessages:OpenControlChannel` permission from the policy which should result in an `ssmmessages:OpenControlChannel: Implicit Deny` error + +* `IncludeExplicitSecurityGroup (True|False)` + + Set to `True` to include a Security Group for the instance that does not reference port 22/SSH + +* `IncludeExplicitSSHSecurityGroup (True|False)` + + Set to `True` to include a Security Group for the instance that does includes an ingress rule for 22/SSH. This should result in an `Security Group contains an SSH ingress rule` error + +* `UseProvidedDefaultVpcValues (True|False)` + + Set to `False` to have the template create a custom VPC and Subnet, and deploy the instance into it + + Set to `True` to deploy the instance into the *default* VPC and Subnet you provide using the `DefaultVpc` and `DefaultVpcSubnetId` parameters. + +* `IncludeSSMKMS (True|False)` + + Set to 'True' to have the template generate an KMS key and reference it with the `kms:decrypt` action in the IAMInstanceRole. + + If `requireSessionManagerEncryption` property in the Hook Configuration is set to `True` and you set `IncludeSSMKMS` to `False`, you should get a `kms:decrypt: Implicit Deny` error + +> **IMPORTANT** + +> During a stack update, if a dependant property of the instance is changed, the hook will not be called. This means its possible during a stack update, to bypass the validation checks such as adding an 22/SSH ingress rule to the instances' referenced Security Group. \ No newline at end of file diff --git a/hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook-configuration.json b/hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook-configuration.json new file mode 100644 index 0000000..3a8d641 --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook-configuration.json @@ -0,0 +1,16 @@ +{ + "properties": { + "requireSessionManagerEncryption": { + "type": "boolean", + "description": "Set to 'true' to require kms:decrypt action with a valid key-name resource", + "enum": [ + true, + false + ], + "default": false + } + }, + "additionalProperties": false, + "definitions": {}, + "typeName": "AWSSamples::Ec2SsmSmOnly::Hook" +} diff --git a/hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook.json b/hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook.json new file mode 100644 index 0000000..a0fc1d0 --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/awssamples-ec2ssmsmonly-hook.json @@ -0,0 +1,50 @@ +{ + "typeName": "AWSSamples::Ec2SsmSmOnly::Hook", + "description": "Checks that EC2 instances being deployed are configured to only allow use of SSM Session Manager to access the instance", + "sourceUrl": "https://github.com/aws-cloudformation/example-sse-hook", + "documentationUrl": "https://github.com/aws-cloudformation/example-sse-hook/blob/master/README.md", + "typeConfiguration": { + "properties": { + "requireSessionManagerEncryption": { + "type": "boolean", + "description": "Set to 'true' to require kms:decrypt action with a valid key-name resource", + "enum": [ + true, + false + ], + "default": false + } + }, + "additionalProperties": false + }, + "required": [], + "handlers": { + "preCreate": { + "targetNames": [ + "AWS::EC2::Instance", + "AWS::EC2::LaunchTemplate" + ], + "permissions": [ + "ec2:DescribeSecurityGroups", + "ec2:DescribeImages", + "iam:GetInstanceProfile", + "iam:SimulatePrincipalPolicy", + "iam:GetRole" + ] + }, + "preUpdate": { + "targetNames": [ + "AWS::EC2::Instance", + "AWS::EC2::LaunchTemplate" + ], + "permissions": [ + "ec2:DescribeSecurityGroups", + "ec2:DescribeImages", + "iam:GetInstanceProfile", + "iam:SimulatePrincipalPolicy", + "iam:GetRole" + ] + } + }, + "additionalProperties": false +} diff --git a/hooks/python-hooks/ec2-ssm-sm-only/hook-role.yaml b/hooks/python-hooks/ec2-ssm-sm-only/hook-role.yaml new file mode 100644 index 0000000..01c541d --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/hook-role.yaml @@ -0,0 +1,44 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during Hook operations on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - hooks.cloudformation.amazonaws.com + - resources.cloudformation.amazonaws.com + Action: sts:AssumeRole + Condition: + StringEquals: + aws:SourceAccount: + Ref: AWS::AccountId + StringLike: + aws:SourceArn: + Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/hook/AWSSamples-Ec2SsmSmOnly-Hook/* + Path: "/" + Policies: + - PolicyName: HookTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "ec2:DescribeImages" + - "ec2:DescribeSecurityGroups" + - "iam:GetInstanceProfile" + - "iam:GetRole" + - "iam:SimulatePrincipalPolicy" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/hooks/python-hooks/ec2-ssm-sm-only/requirements.txt b/hooks/python-hooks/ec2-ssm-sm-only/requirements.txt new file mode 100644 index 0000000..378849a --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/requirements.txt @@ -0,0 +1 @@ +cloudformation-cli-python-lib==2.2.hooks diff --git a/hooks/python-hooks/ec2-ssm-sm-only/resource-role.yaml b/hooks/python-hooks/ec2-ssm-sm-only/resource-role.yaml new file mode 100644 index 0000000..0798d39 --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/resource-role.yaml @@ -0,0 +1,32 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: > + This CloudFormation template creates a role assumed by CloudFormation + during Hook operations on behalf of the customer. + +Resources: + ExecutionRole: + Type: AWS::IAM::Role + Properties: + MaxSessionDuration: 8400 + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: cfnhooksservice.cloudformation.aws.internal + Action: sts:AssumeRole + Path: "/" + Policies: + - PolicyName: ResourceTypePolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - "iam:GetInstanceProfile" + - "iam:SimulatePrincipalPolicy" + Resource: "*" +Outputs: + ExecutionRoleArn: + Value: + Fn::GetAtt: ExecutionRole.Arn diff --git a/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/__init__.py b/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/handlers.py b/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/handlers.py new file mode 100644 index 0000000..4cb0d28 --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/handlers.py @@ -0,0 +1,243 @@ +import logging +import os +import sys +from typing import Any, MutableMapping, Optional + +from cloudformation_cli_python_lib import ( + BaseHookHandlerRequest, + HandlerErrorCode, + Hook, + HookInvocationPoint, + OperationStatus, + ProgressEvent, + SessionProxy, + exceptions, +# HookContext, # debug +) + +from .models import HookHandlerRequest, TypeConfigurationModel +#from models import HookHandlerRequest, TypeConfigurationModel # for local debugging + +#import boto3 # debug + +# Use this logger to forward log messages to CloudWatch Logs. +LOG = logging.getLogger(__name__) +TYPE_NAME = "AWSSamples::Ec2SsmSmOnly::Hook" + +hook = Hook(TYPE_NAME, TypeConfigurationModel) +test_entrypoint = hook.test_entrypoint + +LOG.setLevel(logging.DEBUG) + +# Overwrite the error message for exceptions.NotFound +class NotFound(exceptions.Unknown): + def __init__(self, type_name: str, identifier: str): + super().__init__( + f"Resource of type '{type_name}'; identifier '{identifier}' was not found or is empty." + ) + +def _validate_ec2_ssmsm_only_access(session, hookContext, resourceProperties, typeConfiguration): + + targetLogicalId = hookContext.targetLogicalId + + # Make sure the instance has properties defined + if not resourceProperties: + raise NotFound(targetLogicalId, f"Properties") + + # Check if the instance has a IAMInstanceProfile property defined + iamInstanceProfileName = resourceProperties.get("IamInstanceProfile") + + # Make sure we have a non-empty instance profile name + if not iamInstanceProfileName or (iamInstanceProfileName == ""): + raise NotFound(targetLogicalId, f"IamInstanceProfile") + + LOG.debug(f"iam_instance_profile_name: {iamInstanceProfileName}") + + iam = session.client('iam') + + # Get the profile details + getInstanceProfileResp = iam.get_instance_profile(InstanceProfileName=iamInstanceProfileName) + + # SSM Session Manager requires these actions to operate + ssmSessionManagerPermissions = [ + "ssmmessages:CreateControlChannel", + "ssmmessages:CreateDataChannel", + "ssmmessages:OpenControlChannel", + "ssmmessages:OpenDataChannel" + ] + + if typeConfiguration.requireSessionManagerEncryption == True: + ssmSessionManagerPermissions.append("kms:decrypt") + + actionErrors = [] + + # Iterate over the roles (should only be one role) + for role in getInstanceProfileResp['InstanceProfile']['Roles']: + + # Make sure we have a non-empty role name + roleName = role["RoleName"] + if not roleName or (roleName == ""): + raise NotFound(targetLogicalId, f"{iamInstanceProfileName}.RoleName") + + LOG.debug(f"role_name: {roleName}") + + # Get the role definition + getRoleResp = iam.get_role(RoleName=roleName) + + # Simulate the role using the required SSM SM actions to see if their present and allowed + # 'simulate_principal_policy will 'flatten' out the permissions regardless of whether they + # are the result of managed, inline or inherited policies. + simulatePrincipalPolicyResp = iam.simulate_principal_policy( + PolicyInputList=[], + ActionNames=ssmSessionManagerPermissions, + PolicySourceArn=getRoleResp['Role']['Arn'] + ) + + # Iterate over the simulation results. + for eval_result in simulatePrincipalPolicyResp['EvaluationResults']: + LOG.debug(f"ssm_sm_permissions= {ssmSessionManagerPermissions}") + + # We role contained the action, either as an explicit 'Allow' or explicit 'Deny' + # Remove from the search list + ssmSessionManagerPermissions.remove(eval_result['EvalActionName']) + + # If the action is not explicitly allowed, record the action error. While not typical, its possible the required + # set of actions could be spread out over multiple roles and/or managed policies + if eval_result['EvalDecision'] != 'allowed': + # Save the failed action and reason (explicitDeny | implicitdeny) + actionErrors.append(f"{eval_result['EvalActionName']}: \'{eval_result['EvalDecision']}\'") + + # If we found all of required actions, stop looking + LOG.debug(f"len(ssmSessionManagerPermissions): {len(ssmSessionManagerPermissions)}") + if len(ssmSessionManagerPermissions) == 0: + break + + # After iterating over the InstanceProfile roles, see if any SSM-SM required permissions unaccounted for + if len(ssmSessionManagerPermissions) != 0: + for action in ssmSessionManagerPermissions: + LOG.debug(f"action={action}") + actionErrors.append(f"{action}") + + LOG.debug(f"actionErrors={actionErrors}") + + if len(actionErrors) != 0: + raise exceptions.NonCompliant(TYPE_NAME, f"{targetLogicalId}.IamInstanceProfile({iamInstanceProfileName}).Roles({roleName}) does not support minimum required Session Manager permissions: {', '.join(actionErrors)}") + + # Check if the instance has a SecurityGroupIds property (non-default VPC) + sgPropName = "SecurityGroupIds" + securityGroupIds = resourceProperties.get(sgPropName) + if not securityGroupIds: + + # Check if the instance has a SecurityGroups property (EC2-Classic, default VPC) + sgPropName = "SecurityGroups" + securityGroupIds = resourceProperties.get(sgPropName) + if not securityGroupIds: + + # Check if the instance has a NetworkInterfaces property. If so, iterate over the network interfaces + # and grab all of the security group ids they reference. + sgPropName = "NetworkInterfaces" + networkInterfaces = resourceProperties.get(sgPropName) + if networkInterfaces: + securityGroupIds = [] + for networkInterface in networkInterfaces: + securityGroupIds.append(networkInterface["GroupSet"]) + + # Instance has security-group(s) defined + if securityGroupIds: + + ec2 = session.client("ec2") + + # Using the EC2 instance ImageId, get the 'platform' property for the AMI. If its not found, implies 'not Windows' + # otherwise check if explicitly Windows. + describeImagesResp = ec2.describe_images( + ImageIds=[ + resourceProperties.get("ImageId") + ] + ) + + # Based on the AMI platform, select the security group ingress port to prevent: + # Windows=3389 (RDP) otherwise 22 (SSH) + for image in describeImagesResp["Images"]: + ingressPort = "22/SSH" + amiPlatform = image.get("platform") + if amiPlatform and amiPlatform == "windows": + ingressPort = "3389/RDP" + break; + + # Retrieve the security group(s) filtering on ingress-rules that allow port 22 (SSH) + describeSecurityGroupsResp = ec2.describe_security_groups( + GroupIds=securityGroupIds, + Filters=[ + { + "Name": "ip-permission.from-port", + "Values": [ingressPort.split("/")[0]] + }, + { + "Name": "ip-permission.to-port", + "Values": [ingressPort.split("/")[0]] + } + ] + ) + + # Iterate over the returned list, failing on the first one found as there should not be any + # that allow port 22 (SSH) ingress. + for securityGroup in describeSecurityGroupsResp["SecurityGroups"]: + raise exceptions.NonCompliant(TYPE_NAME, f"{targetLogicalId}.{sgPropName} Security Group {securityGroup['GroupName']} contains an {ingressPort} ingress rule") + + return ProgressEvent(status = OperationStatus.SUCCESS, message = f"Success") + +@hook.handler(HookInvocationPoint.CREATE_PRE_PROVISION) +def pre_create_handler( + session: Optional[SessionProxy], + request: HookHandlerRequest, + callback_context: MutableMapping[str, Any], + type_configuration: TypeConfigurationModel +) -> ProgressEvent: + + LOG.debug(f"session: {session}") + LOG.debug(f"request: {request}") + LOG.debug(f"type_configuration: {type_configuration}") + + try: + + targetName = request.hookContext.targetName + + if targetName == "AWS::EC2::Instance": + return _validate_ec2_ssmsm_only_access(session, request.hookContext, request.hookContext.targetModel.get("resourceProperties"), type_configuration) + + elif targetName == "AWS::EC2::LaunchTemplate": + return _validate_ec2_ssmsm_only_access(session, request.hookContext, request.hookContext.targetModel.get("resourceProperties")['LaunchTemplateData'], type_configuration) + + else: + raise exceptions.InvalidRequest( + f"Unexpected target type: {targetName}") + + except exceptions._HandlerError as e: + raise + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + LOG.debug(f"Unexpected exception: {e}") + LOG.debug(exc_type, fname, exc_tb.tb_lineno) + raise + +@hook.handler(HookInvocationPoint.UPDATE_PRE_PROVISION) +def pre_update_handler( + session: Optional[SessionProxy], + request: BaseHookHandlerRequest, + callback_context: MutableMapping[str, Any], + type_configuration: TypeConfigurationModel +) -> ProgressEvent: + + try: + # Resource updates are checked same as creation + return pre_create_handler(session, request, callback_context, type_configuration) + + except exceptions._HandlerError as e: + raise + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + LOG.debug(f"Unexpected exception: {e}") + LOG.debug(exc_type, fname, exc_tb.tb_lineno) + raise diff --git a/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/models.py b/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/models.py new file mode 100644 index 0000000..3b7ef7b --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/src/awssamples_ec2ssmsmonly_hook/models.py @@ -0,0 +1,54 @@ +# DO NOT modify this file by hand, changes will be overwritten +import sys +from dataclasses import dataclass +from inspect import getmembers, isclass +from typing import ( + AbstractSet, + Any, + Generic, + Mapping, + MutableMapping, + Optional, + Sequence, + Type, + TypeVar, +) + +from cloudformation_cli_python_lib.interface import BaseHookHandlerRequest, BaseModel +from cloudformation_cli_python_lib.recast import recast_object +from cloudformation_cli_python_lib.utils import deserialize_list + +T = TypeVar("T") + + +def set_or_none(value: Optional[Sequence[T]]) -> Optional[AbstractSet[T]]: + if value: + return set(value) + return None + + +@dataclass +class HookHandlerRequest(BaseHookHandlerRequest): + pass + + +@dataclass +class TypeConfigurationModel(BaseModel): + requireSessionManagerEncryption: Optional[bool] + + @classmethod + def _deserialize( + cls: Type["_TypeConfigurationModel"], + json_data: Optional[Mapping[str, Any]], + ) -> Optional["_TypeConfigurationModel"]: + if not json_data: + return None + return cls( + requireSessionManagerEncryption=json_data.get("requireSessionManagerEncryption"), + ) + + +# work around possible type aliasing issues when variable has same name as a model +_TypeConfigurationModel = TypeConfigurationModel + + diff --git a/hooks/python-hooks/ec2-ssm-sm-only/template.yml b/hooks/python-hooks/ec2-ssm-sm-only/template.yml new file mode 100644 index 0000000..825369c --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/template.yml @@ -0,0 +1,24 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: AWS SAM template for the AWSSamples::Ec2SsmSmOnly::Hook resource type + +Globals: + Function: + Timeout: 180 # docker start-up times can be long for SAM CLI + MemorySize: 256 + +Resources: + TypeFunction: + Type: AWS::Serverless::Function + Properties: + Handler: awssamples_ec2ssmsmonly_hook.handlers.hook + Runtime: python3.6 + CodeUri: build/ + + TestEntrypoint: + Type: AWS::Serverless::Function + Properties: + Handler: awssamples_ec2ssmsmonly_hook.handlers.test_entrypoint + Runtime: python3.6 + CodeUri: build/ + diff --git a/hooks/python-hooks/ec2-ssm-sm-only/tests/use-cases.yml b/hooks/python-hooks/ec2-ssm-sm-only/tests/use-cases.yml new file mode 100644 index 0000000..6e106f0 --- /dev/null +++ b/hooks/python-hooks/ec2-ssm-sm-only/tests/use-cases.yml @@ -0,0 +1,252 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Tests::EC2::EnforceSsmHook against arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore ManagedPolicyArn +Parameters: + + LatestAmiId: + Type: 'AWS::SSM::Parameter::Value' + Default: '/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2' + + ManagedOrManualIAMPolicy: + Description: Select 'Manual' to use apply SSM Session Manager policies. + Type: String + AllowedValues: + - Managed + - Manual + Default: Managed + + TestIllegalManualSSMPolicies: + Description: Select 'True' to simulate a failure scenario that should be caught by the hook + Type: String + AllowedValues: + - True + - False + Default: False + + IncludeInstanceProfile: + Description: Select 'False' to simulate a failure scenario that should be caught by the hook + Type: String + AllowedValues: + - True + - False + Default: True + + IncludeSSMKMS: + Description: Select 'True' to include a KMS key for SSM Session Encryption + Type: String + AllowedValues: + - True + - False + Default: False + + IncludeExplicitSecurityGroup: + Description: Select 'True' to include an explicit Security Group + Type: String + AllowedValues: + - True + - False + Default: False + + IncludeExplicitSSHSecurityGroup: + Description: Select 'True' to include an explicit SSH Security Group + Type: String + AllowedValues: + - True + - False + Default: False + + UseProvidedDefaultVpcValues: + Description: Select 'True' to provide default VpcId and SubnetId + Type: String + AllowedValues: + - True + - False + Default: False + + DefaultVpcId: + Description: VpcId of the default VPC + Type: AWS::EC2::VPC::Id + + DefaultVpcSubnetId: + Description: SubnetId in the provided default VPC + Type: AWS::EC2::Subnet::Id + +Conditions: + CreateVpcAndSubnet: !Equals [!Ref UseProvidedDefaultVpcValues, False] + ApplyInstanceProfile: !Equals [!Ref IncludeInstanceProfile, True] + ApplyManualSSMIamPolicies: !Equals [!Ref ManagedOrManualIAMPolicy, Manual] + ApplyManagedSSMIamPolicies: !Equals [!Ref ManagedOrManualIAMPolicy, Managed] + ApplySSMKMSKey: !Equals [!Ref IncludeSSMKMS, 'True'] + ApplyIllegalManualSSMPolicies: !Equals [!Ref TestIllegalManualSSMPolicies, True] + UseInstanceProfileAndManagedPolicy: !And [!Condition ApplyInstanceProfile, !Condition ApplyManagedSSMIamPolicies] + UseInstanceProfileAndManualPolicy: !And [!Condition ApplyInstanceProfile, !Condition ApplyManualSSMIamPolicies] + ApplyExplicitSecurityGroup: !Equals [!Ref IncludeExplicitSecurityGroup, True] + ApplyExplicitSSHSecurityGroup: !Equals [!Ref IncludeExplicitSSHSecurityGroup, True] + ApplySecurityGroups: !Or [!Condition ApplyExplicitSecurityGroup, !Condition ApplyExplicitSSHSecurityGroup] + +Resources: + + SsmKmsKey: + Condition: ApplySSMKMSKey + Type: AWS::KMS::Key + Properties: + Description: "KMS Key for SSM Session Manager Encryption" + Enabled: true + KeyPolicy: + Version: 2012-10-17 + Id: auto-ssm-1 + Statement: + - Sid: Allow access through SSM for all principals in the account that are + authorized to use SSM + Effect: Allow + Principal: + AWS: "*" + Action: + - kms:* + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: !Ref AWS::AccountId + kms:ViaService: !Sub ssm.${AWS::Region}.amazonaws.com + - Sid: Allow direct access to key metadata to the account + Effect: Allow + Principal: + AWS: !Sub arn:aws:iam::${AWS::AccountId}:root + Action: + - kms:* + Resource: "*" + + MyVPC: + Condition: CreateVpcAndSubnet + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + + MyPubSubnet: + Condition: CreateVpcAndSubnet + Type: AWS::EC2::Subnet + Properties: + VpcId: !Ref MyVPC + CidrBlock: 10.0.0.0/24 + + MyExplicitSecurityGroup: + Condition: ApplyExplicitSecurityGroup + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: EC2 SecuritryGroup + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: 0.0.0.0/0 + VpcId: !If [CreateVpcAndSubnet, !Ref MyVPC, !Ref DefaultVpcId] + + MyExplicitSSHSecurityGroup: + Condition: ApplyExplicitSSHSecurityGroup + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: EC2 SecuritryGroup + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 22 + ToPort: 22 + CidrIp: 0.0.0.0/0 + VpcId: !If [CreateVpcAndSubnet, !Ref MyVPC, !Ref DefaultVpcId] + + MyInstanceRoleWithManagedPolicyRole: + Condition: UseInstanceProfileAndManagedPolicy + Type: AWS::IAM::Role + Properties: + Description: Amazon EC2 Role to enable AWS Systems Manager service core functionality. + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - 'sts:AssumeRole' + Path: / + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + Policies: + - !If + - ApplySSMKMSKey + - PolicyName: SSMSessionManagerKMSEncryptionPermissions + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - s3:GetEncryptionConfiguration + Resource: "*" + - Effect: Allow + Action: + - kms:Decrypt + Resource: !GetAtt SsmKmsKey.Arn + - !Ref AWS::NoValue + + MyInstanceRoleWithManualPolicyRole: + Condition: UseInstanceProfileAndManualPolicy + Type: AWS::IAM::Role + Properties: + Description: Amazon EC2 Role to enable AWS Systems Manager service core functionality. + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: + - ec2.amazonaws.com + Action: + - 'sts:AssumeRole' + Path: / + Policies: + - PolicyName: SSMSessionManagerManualPermissions + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - ssmmessages:CreateControlChannel + - ssmmessages:CreateDataChannel + - !If [ApplyIllegalManualSSMPolicies, !Ref AWS::NoValue, ssmmessages:OpenControlChannel] + - ssmmessages:OpenDataChannel + Resource: "*" + - !If + - ApplySSMKMSKey + - PolicyName: SSMSessionManagerKMSEncryptionPermissions + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - s3:GetEncryptionConfiguration + Resource: "*" + - Effect: Allow + Action: + - kms:Decrypt + Resource: !GetAtt SsmKmsKey.Arn + - !Ref AWS::NoValue + + MyInstanceProfile: + Condition: ApplyInstanceProfile + Type: AWS::IAM::InstanceProfile + Properties: + Path: / + Roles: + - !If [ApplyManualSSMIamPolicies, !Ref MyInstanceRoleWithManualPolicyRole, !Ref MyInstanceRoleWithManagedPolicyRole] + + EC2Instance: + Type: AWS::EC2::Instance + Properties: + ImageId: !Ref LatestAmiId + InstanceType: t3.medium + IamInstanceProfile: !If [ApplyInstanceProfile, !Ref MyInstanceProfile, !Ref AWS::NoValue] + SubnetId: !If [CreateVpcAndSubnet, !Ref MyPubSubnet, !Ref DefaultVpcSubnetId] + SecurityGroupIds: !If + - ApplySecurityGroups + - - !If [ApplyExplicitSecurityGroup, !Ref MyExplicitSecurityGroup, !Ref AWS::NoValue] + - !If [ApplyExplicitSSHSecurityGroup, !Ref MyExplicitSSHSecurityGroup, !Ref AWS::NoValue] + - !Ref AWS::NoValue +