This guide describes how to create a new rule to be used by cfn-lint
.
An overview of all the rules that are currently supported can be found here.
A rule is a standalone Python class inherited from the base rule: CloudFormationLintRule
that checks the given CloudFormation template for invalid JSON/YAML syntax, deployment errors, best practices, unused resources and other possible issues in the template.
Besides the rules from cfn-lint
itself, it can also process external rules by using the -a/--append-rules
argument, making it possible to integrate custom rulesets.
When creating a rule for cfn-lint
, keep the following rules in mind:
- A rule has to be applicable to ALL users of the toolkit by default. If a rule contains specific business logic, create a custom rule.
- A rule typically covers a single specific use case. It's not an exact science, but create multiple rules if you think it's needed.
- A rule is an
Error
(blocking) orWarning
(not blocking). Create multiple specific rules if a rule can be both. - A rule should focus on its own requirements. Missing a required property? That's handled by an existing rule already.
These rules don't apply to custom rules sets, you're free to build your own rules.
A rule returns its findings by returning a list of RuleMatch
objects. A RuleMatch
is an object containing information about the finding:
- Path to the resource. Used for returning line/column information of the finding in the file.
- The message about the finding. This is a specific error message that can be formatted to contain detailed information about the error (it's not the Rule's description).
See the code snippets section for an example on how to add a finding .
The following steps describe the basics on how to create a new rule.
As an example we use MyNewRule
as the rule to be added.
Go to the rules documentation and find an appropriate available Rule ID, based on the correct category.
Based on the use case and rule category, create a new Python file in the correct namespace of the rules folder. Use the name of the Rule, so the filename of MyNewRule
is mynewrule.py
.
Use the following skeleton code as a starting point of your new rule:
"""
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: MIT-0
"""
from cfnlint.rules import CloudFormationLintRule
from cfnlint.rules import RuleMatch
class MyNewRule(CloudFormationLintRule):
id = '' # New Rule ID
shortdesc = '' # A short description about the rule
description = '' # (Longer) description about the rule
source_url = '' # A url to the source of the rule, e.g. documentation, AWS Blog posts etc
tags = [] # A set of tags (strings) for searching
def match(self, cfn):
"""Basic Rule Matching"""
matches = []
# Your Rule code goes here
return matches
Fill in the metadata (id
, shortdesc
, description
, source_url
, tags
) of your new rule, these are required attributes needed for processing the rule.
Since cfn-lint
loads all rules from the rules
folder as a plugin, the file and Class name must be the same.
At this point your new rule is already part of the linter's ruleset!
See the code snippets section below for some examples of an implemention.
You can test your new rule by just running cfn-lint
, but preferably your new rule is validated by unit tests.
Fixture templates are created under the test/fixtures/templates/good (for templates with valid CloudFormation code) or test/fixtures/templates/bad (for template with invalid CloudFormation code) folders.
Use YAML
for the CloudFormation templates for readability and the ability to add comments in the template
Create a test_mynewrule.py
at the appropriate location in the test/unit/rules/
folder.
Use the following skeleton code as a starting point of your new rule:
"""
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: MIT-0
"""
from cfnlint.rules.MyNewRule import MyNewRule # pylint: disable=E0401
from .. import BaseRuleTestCase
class TestMyNewRule(BaseRuleTestCase):
"""Test template parameter configurations"""
def setUp(self):
super(TestMyNewRule, self).setUp()
self.collection.register(MyNewRule())
def test_file_positive(self):
self.helper_file_positive() # By default, a set of "correct" templates are checked
def test_file_negative(self):
self.helper_file_negative('test/fixtures/templates/bad/mynewrule.yaml', 1) # Amount of expected matches
As you can see test_file_negative()
in this unit test makes specific use of a CloudFormation template from the Fixtures folder. It's important to provide examples of templates which pass your new rule, and also templates which generate the expected warning/error.
Please see detailed instructions here.
The skeleton code mentioned in the Create new Rule step contains no real code to keep it as simple as possible.
This section contains a few simple and straightforward snippets to get you started. For more complex solutions, take a deep-dive in the code of the existing rules!
The following snippet is a simple example on how to return a finding for every resource in the template:
# Get all resources
resources = cfn.get_resources()
matches = []
# Loop over all the found resources
for resource_name, resource in resources.items():
# The path is used to determine the line/column number when returning the Match information.
path = ['Resources', resource_name]
# The error message with specific error information
message = 'An error message about resource {0}'
matches.append(RuleMatch(path, message.format(resource_name)))
return matches
The following snippet is a simple example on checking a specific resource type:
# Get all EC2 instances from the template
resources = cfn.get_resources(['AWS::EC2::Instance'])
matches = []
# Loop over all the found resources
for resource_name, resource in resources.items():
# Check the InstanceType setting
properties = resource.get('Properties')
# Only check what we need. Other rules handle misconfigurations
if properties:
# Check the instance type specified
if properties.get('InstanceType') == 't2.small':
# The path is used to determine the line/column number when returning the Match information.
path = ['Resources', resource_name, 'Properties', 'InstanceType']
message = 'Please do not use t2.small'
matches.append(RuleMatch(path, message.format(resource_name)))
return matches
The following snippet is a simple example on checking specific property values:
def check_value(self, value, path):
"""Check SecurityGroup descriptions"""
matches = []
# Check max length
if len(value) > 255:
message = 'GroupDescription length ({0}) exceeds the limit (255) at {1}'
full_path = '/'.join(str(x) for x in path)
matches.append(RuleMatch(path, message.format(len(value), full_path)))
return matches
def match(self, cfn):
"""Check SecurityGroup descriptions"""
resources = cfn.get_resources(['AWS::EC2::SecurityGroup'])
matches = []
for resource_name, resource in resources.items():
path = ['Resources', resource_name, 'Properties']
properties = resource.get('Properties')
if properties:
matches.extend(
cfn.check_value(
properties, 'GroupDescription', path,
check_value=self.check_value
)
)
return matches
The check_value
method in the Template
class is a helper function to check values in a quick and simple way. It supports the handling of multiple values:
def check_value(self, obj, key, path,
check_value=None, check_ref=None,
check_find_in_map=None, check_split=None, check_join=None,
check_import_value=None, check_sub=None,
**kwargs):
...