Developers today provision resources to build applications using security, operational, and cost optimization best practices. While provisioning these resources, developers must maintain compliance for them, and the compliance status is usually known after the resources are provisioned. The non-compliant resources then must be deleted after the provisioning because of no checks for them; this problem is handled by a new CloudFormation feature called AWS CloudFormation Hooks.
CloudFormation Hooks is an extension type in the AWS CloudFormation registry and makes it easier to distribute and consume Hooks publicly or privately, with versioning support.
This blog will show you how to author and deploy a hook from your private registry. In this blog, we will try to stop provisioning a CloudFormation stack that creates a Security Group with ’0.0.0.0/0’ at ingress.
Before we start performing the compliance for this particular use case, you can go through the basic terminologies here and what is a Type configuration (which will be used while we create the CloudFormation stack via CLI)
Creating Custom CloudFormation Hooks
Ensure you have the prerequisites mentioned below and are familiar with AWS CloudFormation templates, Python, and Docker (although we won’t be using Docker here, it is best to know about platform-independent packaging of Python dependencies).
1. AWS Account and AWS CLI V2.
2. Download and install Python 3.6 or later.
3. Use the commands given below to install the cloudformation-cli(cfn) and Python language plugin. Also, upgrade these using the another link given.
Installation link:
$ pip3 install cloudformation-cli cloudformation-cli-python-plugin
Upgrade link: (Make sure to Upgrade!)
$ pip3 install --upgrade cloudformation-cli cloudformation-cli-python-plugin
4. Make sure you have configured AWS CLI with your credentials.
$ aws configure
Default region name [region]: <preferred region>
5. Permissions needed:
Initiate the Project for The CF Hook
Since we use a private repository, we will work with AWS cli on our local machine in this example.
1. We first create a directory and initiate the project inside it. We use the cfn init command to initiate the project. This creates your hook project and generates the required files. Run the following commands: (Our directory name used here is “first hook”)
$ mkdir firsthook
$ cd firsthook
$ cfn init
2. The cloud formation–cli prompts you to choose between options to create a new resource, module, or hook. Since we want to create a hook, we choose ‘h.’
3. Next prompt will be to name the hook type, which is used to map the Typeattribute for resources in CF template. Make sure this name is consistent throughout this compliance practice.
4. Next will be to choose a language plugin, the supported languages include Python 3.6, Python 3.7, and Java. We choose Python 3.7 as you can see below:
5. To make the development easier, it is recommended to choose Docker for packaging, but since we are doing a very simple activity and we won’t be needing packaging Python dependencies, we choose not to opt for docker as shown: After you complete the initialization of the project in the directory, you should be able to see the below files:
Create The Hook
A hook contains two major components, one is the specification part which is defined by JSON schema, and another is a set of handlers at each invocation point. After creating these components, the hook must be registered and enabled in the used AWS account.
Step 1: Crafting the schema
The JSON schema defines the hook, its properties, and the attributes. An example JSON schema file is created when the project is initiated (<hook name>.json). You can use this JSON schema file to make the changes. Since we are going to use our hook to govern the Security Ingress rule, our JSON schema will look something like this:
{
“typeName”: “Rapyder::COETest::firsthook”,
“description”: “Validates that Security Groups do not allow inbound traffic from any address (0.0.0.0/0/ or ::/0).”,
“sourceUrl”: “https://github.com/aws-cloudformation/aws-cloudformation-samples/tree/main/hooks/python-hooks/security-group-open-ingress”,
“documentationUrl”: “https://github.com/aws-cloudformation/aws-cloudformation-samples/blob/main/hooks/python-hooks/security-group-open-ingress/README.md”,
“typeConfiguration”: {
“properties”: {},
“additionalProperties”: false
},
“required”: [],
“handlers”: {
“preCreate”: {
“targetNames”: ,
“permissions”: []
},
“preUpdate”: {
“targetNames”: ,
“permissions”: []
}
},
“additionalProperties”: false
}
Here under the “preCreate” handler, the target are “AWS::EC2::SecurityGroup”, “AWS::EC2::SecurityGroupIngress”. This means everytime a Security Group resource and Ingress rules are created; this hook will be invoked. Then “preUpdate” will be invoked before an update (when a rule is changed or another SG is added) is completed, that is the update must pass this hook for the specified targets.
Step 2: Generate The Hook Project Package
Next step is to generate a hook project package. Use command below:
$ cfn generate
Generated files for Rapyder::COETest::firsthook
The cloudformation-cli will create empty handler functions. Each handler created corresponds to a hook invocation point.
Step 3: Hook Handler Code
Copy the below code in handler.py which is located in the src/ Rapyder_COETest_firsthook directory. Replace the code inside it with the below one:
import logging
from cloudformation_cli_python_lib import (
HandlerErrorCode,
Hook,
HookInvocationPoint,
OperationStatus,
ProgressEvent
)
from .models import HookHandlerRequest, TypeConfigurationModel
# Use this logger to forward log messages to CloudWatch Logs.
LOG = logging.getLogger(__name__)
TYPE_NAME = “Rapyder::SecurityGroup::firsthook”
hook = Hook(TYPE_NAME, TypeConfigurationModel)
test_entrypoint = hook.test_entrypoint
supported_types = [“AWS::EC2::SecurityGroup”, “AWS::EC2::SecurityGroupIngress”]
def non_compliant(msg):
LOG.debug(f”returning FAILED: {HandlerErrorCode.NonCompliant} {msg}”)
return ProgressEvent(
status=OperationStatus.FAILED,
errorCode=HandlerErrorCode.NonCompliant,
message=msg
)
def is_open(sg_list):
for sg in sg_list:
if sg.get(‘CidrIp’) == ‘0.0.0.0/0’ or sg.get(‘CidrIpv6’) == ‘::/0’:
return True
return False
@hook.handler(HookInvocationPoint.CREATE_PRE_PROVISION)
@hook.handler(HookInvocationPoint.UPDATE_PRE_PROVISION)
def pre_handler(_s, request: HookHandlerRequest, _c, type_configuration: TypeConfigurationModel) -> ProgressEvent:
LOG.setLevel(logging.DEBUG)
LOG.debug(f”request: {request.__dict__}”)
LOG.debug(f”type_configuration: {type_configuration.__dict__ if type_configuration else dict()}”)
cfn_model = request.hookContext.targetModel.get(“resourceProperties”, {})
cfn_type = request.hookContext.targetName
# If we get a type that we don’t care about, we should return InvalidRequest
if cfn_type not in supported_types:
LOG.error(“returning invalidRequest”)
return ProgressEvent(
status=OperationStatus.FAILED,
errorCode=HandlerErrorCode.InvalidRequest,
message=f”This hook only supports {supported_types}”
)
if cfn_type == “AWS::EC2::SecurityGroup”:
security_groups = cfn_model.get(“SecurityGroupIngress”, [])
else:
security_groups = [cfn_model] if cfn_model else []
# Fail if an open ingress rule is found
if is_open(security_groups):
return non_compliant(“Security Group cannot contain rules allow all destinations (0.0.0.0/0 or ::/0)”)
# Operation is compliant, return success
LOG.debug(“returning SUCCESS”)
return ProgressEvent(status=OperationStatus.SUCCESS)
Step 4: Registering The Created Hook
1.Before submitting the hook, dry run the command for submitting to check for errors. (If using Docker, it is crucial to ensure it is up and running before running the command, else you will run into a “Unhandled exception” error.)
2. After the dry run is successful, submit using:$ cfn submit –set-default
After submitting, this command calls the API to register your hook and keeps polling for registration until finished. If any errors occur, then you will know its details with detailed message.
After successful registration, you should see {‘ProgressStatus’: ‘COMPLETE’} which means your hook is registered.
Updating the hook: One can update the hook by updating the handler code and repeating the Registering hook steps.
Step 5: Enabling the Registered Hook
Follow the below steps to enable the hook:
1.Check if you can see your hook in the list after you run the command below:
$ aws cloudformation list-types
2. Copy the hook arn from the response.
3. Now, you need to modify your hook’s type configuration properties. There are three configuration properties, TargetStacks, FailureMode, andSsmKey more details here.
4. Since we are working via our local machine cmd, we can use the command:aws cloudformation –region us-east-1 set-type-configuration –configuration file://type_config.json –type HOOK –type-name Rapyder::COETest::firsthook
Here, the configuration file contains the Hook configuration schema. Create a file in the same folder and run the above command. Also, instead of the hook arn, we use the type name, which is from the JSON schema we defined earlier.
Alternatively, you can use:$ aws cloudformation set-type-configuration
–configuration
\”{\\\”CloudFormationConfiguration\\\”:{\\\”HookConfiguration\\\”:{\\\”TargetStacks\\\”:\\\”ALL\\\”,\\\”FailureMode\\\”:\\\”FAIL\”}}}\”
–type-arn $HOOK_TYPE_ARN
Caution: If you activate hooks from the public registry, you must set the type configuration to ensure the hooks apply to all stacks.
Note: The type_config.json content:
{
\”CloudFormationConfiguration\”: {
\”HookConfiguration\”: {
\”TargetStacks\”: \”ALL\”,
\”FailureMode\”: \”FAIL\”,
\”Properties\”: {}
}
}
}
Testing Your CloudFormation Hook
Create a CloudFormation template named OpenSecurityGroup.yml. Use the below code which will create a Security Group with ingress rule allowing 0.0.0.0/0:AWSTemplateFormatVersion: \”2010-09-09\”
Resources:
SecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: \”Open Ingress Rules! Beware!\”
SecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !GetAtt SecurityGroup.GroupId
IpProtocol: -1
CidrIp: 10.0.0.0/16
Next, create using the AWS CLI using the command:aws cloudformation create-stack –stack-name my-first-hook-stack –template-body file://OpenSecurityGroup.yml
The expected behaviour is that the stack creating will fail with details in CloudFormation events:(From console)
Hence, compliance works!
Be sure to clean up the resources and deregister the hooks using:$ aws cloudformation deregister-type –type HOOK –type-name Rapyder::COETest::firsthook
In the future, we will explore more use cases related to this CloudFormation and AWS Resource creation compliance, stay tuned!