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