diff --git a/docs/custom_rules.md b/docs/custom_rules.md index dc97ac761b..00268e9d14 100644 --- a/docs/custom_rules.md +++ b/docs/custom_rules.md @@ -41,9 +41,12 @@ The specified operator to be used for this rule. The supported values are define | == | Identical to `EQUALS` | | NOT_EQUALS | Checks the specified property is not equal to the value given | | != | Identical to `NOT_EQUALS` | +| REGEX_MATCH | Checks the specified property matches regex given (using python `regex` module) | | IN | Checks the specified property is equal to or contained by the array value | | NOT_IN | Checks the specified property is not equal to or not contained by the array value | | \>= | Checks the specified property is greater than or equal to the value given | +| \> | Checks the specified property is greater than the value given | +| < | Checks the specified property is less than the value given | | <= | Checks the specified property is less than or equal to the value given | | IS | Checks the specified property is defined or not defined, the value must be one of DEFINED or NOT_DEFINED | diff --git a/src/cfnlint/rules/custom/Operators.py b/src/cfnlint/rules/custom/Operators.py index aad25810e4..fdc36b75ae 100644 --- a/src/cfnlint/rules/custom/Operators.py +++ b/src/cfnlint/rules/custom/Operators.py @@ -3,16 +3,21 @@ SPDX-License-Identifier: MIT-0 """ +import regex as re + # pylint: disable=cyclic-import import cfnlint.rules OPERATOR = [ "EQUALS", "NOT_EQUALS", + "REGEX_MATCH", "==", "!=", "IN", "NOT_IN", + ">", + "<", ">=", "<=", "IS DEFINED", @@ -298,7 +303,29 @@ def rule_func(value, expected_values, path): ) -def CreateGreaterRule(rule_id, resourceType, prop, value, error_message): +def CreateRegexMatchRule(rule_id, resourceType, prop, value, error_message): + def rule_func(value, expected_values, path): + matches = [] + if not re.match(expected_values.strip(), str(value).strip()): + matches.append( + cfnlint.rules.RuleMatch(path, error_message or "Regex does not match") + ) + + return matches + + return CreateCustomRule( + rule_id, + resourceType, + prop, + value, + error_message, + shortdesc="Custom rule to check for regex match", + description="Created from the custom rules parameter. This rule will check if a property value match the provided regex pattern.", + rule_func=rule_func, + ) + + +def CreateGreaterEqualRule(rule_id, resourceType, prop, value, error_message): def rule_func(value, expected_value, path): matches = [] if checkInt(str(value).strip()) and checkInt(str(expected_value).strip()): @@ -329,7 +356,69 @@ def rule_func(value, expected_value, path): ) +def CreateGreaterRule(rule_id, resourceType, prop, value, error_message): + def rule_func(value, expected_value, path): + matches = [] + if checkInt(str(value).strip()) and checkInt(str(expected_value).strip()): + if int(str(value).strip()) <= int(str(expected_value).strip()): + matches.append( + cfnlint.rules.RuleMatch( + path, error_message or "Greater than check failed" + ) + ) + else: + matches.append( + cfnlint.rules.RuleMatch( + path, error_message or "Given values are not numeric" + ) + ) + + return matches + + return CreateCustomRule( + rule_id, + resourceType, + prop, + value, + error_message, + shortdesc="Custom rule to check for if a value is greater than the specified value", + description="Created from the custom rules parameter. This rule will check if a property value is greater than the specified value.", + rule_func=rule_func, + ) + + def CreateLesserRule(rule_id, resourceType, prop, value, error_message): + def rule_func(value, expected_value, path): + matches = [] + if checkInt(str(value).strip()) and checkInt(str(expected_value).strip()): + if int(str(value).strip()) >= int(str(expected_value).strip()): + matches.append( + cfnlint.rules.RuleMatch( + path, error_message or "Lesser than check failed" + ) + ) + else: + matches.append( + cfnlint.rules.RuleMatch( + path, error_message or "Given values are not numeric" + ) + ) + + return matches + + return CreateCustomRule( + rule_id, + resourceType, + prop, + value, + error_message, + shortdesc="Custom rule to check for if a value is lesser than the specified value", + description="Created from the custom rules parameter. This rule will check if a property value is lesser than the specified value.", + rule_func=rule_func, + ) + + +def CreateLesserEqualRule(rule_id, resourceType, prop, value, error_message): def rule_func(value, expected_value, path): matches = [] if checkInt(str(value).strip()) and checkInt(str(expected_value).strip()): @@ -363,7 +452,9 @@ def rule_func(value, expected_value, path): def CreateInSetRule(rule_id, resourceType, prop, value, error_message): def rule_func(value, expected_values, path): matches = [] - if value not in expected_values: + if isinstance(expected_values, list): + expected_values = [str(x) for x in expected_values] + if str(value) not in expected_values: matches.append( cfnlint.rules.RuleMatch(path, error_message or "In set check failed") ) @@ -385,7 +476,9 @@ def rule_func(value, expected_values, path): def CreateNotInSetRule(rule_id, resourceType, prop, value, error_message): def rule_func(value, expected_values, path): matches = [] - if value in expected_values: + if isinstance(expected_values, list): + expected_values = [str(x) for x in expected_values] + if str(value) in expected_values: matches.append( cfnlint.rules.RuleMatch( path, error_message or "Not in set check failed" diff --git a/src/cfnlint/rules/custom/__init__.py b/src/cfnlint/rules/custom/__init__.py index 9b03095c61..23d8cf132d 100644 --- a/src/cfnlint/rules/custom/__init__.py +++ b/src/cfnlint/rules/custom/__init__.py @@ -72,6 +72,10 @@ def process_sets(raw_value): return cfnlint.rules.custom.Operators.CreateNotEqualsRule( error_level + str(rule_id), resourceType, prop, value, error_message ) + if operator == "REGEX_MATCH": + return cfnlint.rules.custom.Operators.CreateRegexMatchRule( + error_level + str(rule_id), resourceType, prop, value, error_message + ) if operator == "IN": return cfnlint.rules.custom.Operators.CreateInSetRule( error_level + str(rule_id), resourceType, prop, value, error_message @@ -80,14 +84,22 @@ def process_sets(raw_value): return cfnlint.rules.custom.Operators.CreateNotInSetRule( error_level + str(rule_id), resourceType, prop, value, error_message ) - if operator == ">=": + if operator == ">": return cfnlint.rules.custom.Operators.CreateGreaterRule( error_level + str(rule_id), resourceType, prop, value, error_message ) - if operator == "<=": + if operator == ">=": + return cfnlint.rules.custom.Operators.CreateGreaterEqualRule( + error_level + str(rule_id), resourceType, prop, value, error_message + ) + if operator == "<": return cfnlint.rules.custom.Operators.CreateLesserRule( error_level + str(rule_id), resourceType, prop, value, error_message ) + if operator == "<=": + return cfnlint.rules.custom.Operators.CreateLesserEqualRule( + error_level + str(rule_id), resourceType, prop, value, error_message + ) if operator == "IS": if value in ["DEFINED", "NOT_DEFINED"]: return cfnlint.rules.custom.Operators.CreateCustomIsDefinedRule( diff --git a/src/cfnlint/rules/generic_match_project_validate_rule.py b/src/cfnlint/rules/generic_match_project_validate_rule.py new file mode 100644 index 0000000000..38ced03a00 --- /dev/null +++ b/src/cfnlint/rules/generic_match_project_validate_rule.py @@ -0,0 +1,206 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" +import logging + +import regex as re +import yaml + +import cfnlint.rules +import cfnlint.rules.custom +import cfnlint.rules.custom.Operators + +LOGGER = logging.getLogger(__name__) + +Operators = { + "EQUALS": lambda value, expected_value: str(value).strip().lower() + == str(expected_value).strip().lower(), + "NOT_EQUALS": lambda value, expected_value: str(value).strip().lower() + != str(expected_value).strip().lower(), + "==": lambda value, expected_value: str(value).strip().lower() + == str(expected_value).strip().lower(), + "!=": lambda value, expected_value: str(value).strip().lower() + != str(expected_value).strip().lower(), + ">": lambda value, expected_value: str(value).strip().isnumeric() + and str(expected_value).strip().isnumeric() + and float(value) > float(expected_value), + ">=": lambda value, expected_value: str(value).strip().isnumeric() + and str(expected_value).strip().isnumeric() + and float(value) >= float(expected_value), + "<": lambda value, expected_value: str(value).strip().isnumeric() + and str(expected_value).strip().isnumeric() + and float(value) < float(expected_value), + "<=": lambda value, expected_value: str(value).strip().isnumeric() + and str(expected_value).strip().isnumeric() + and float(value) <= float(expected_value), + "IN": lambda value, expected_values: str(value).strip().lower() + in [str(i).strip().lower() for i in expected_values], + "NOT_IN": lambda value, expected_values: str(value).strip().lower() + not in [str(i).strip().lower() for i in expected_values], + "IS": lambda value, expected_value: ( + expected_value == "DEFINED" and value is not None + ) + or (expected_value == "NOT_DEFINED" and value is None), + "REGEX_MATCH": lambda value, pattern: re.match(pattern, value), +} + +CreateRuleFromOp = { + "EQUALS": cfnlint.rules.custom.Operators.CreateEqualsRule, + "NOT_EQUALS": cfnlint.rules.custom.Operators.CreateNotEqualsRule, + "==": cfnlint.rules.custom.Operators.CreateEqualsRule, + "!=": cfnlint.rules.custom.Operators.CreateNotEqualsRule, + ">": cfnlint.rules.custom.Operators.CreateGreaterRule, + ">=": cfnlint.rules.custom.Operators.CreateGreaterEqualRule, + "<": cfnlint.rules.custom.Operators.CreateLesserRule, + "<=": cfnlint.rules.custom.Operators.CreateLesserEqualRule, + "IN": cfnlint.rules.custom.Operators.CreateInSetRule, + "NOT_IN": cfnlint.rules.custom.Operators.CreateNotInSetRule, + "IS": cfnlint.rules.custom.Operators.CreateCustomIsDefinedRule, + "REGEX_MATCH": cfnlint.rules.custom.Operators.CreateRegexMatchRule, +} + + +def flatten(x): + for el in x: + if isinstance(el, list): + yield from flatten(el) + else: + yield el + + +class MatchProjectValidateRule(cfnlint.rules.CloudFormationLintRule): + def __init__( + self, + rule_id=None, + rule_definition=None, + ): + super().__init__() + if rule_id is None and rule_definition is None: + self.do_nothing = True + return + self.do_nothing = False + self.id = rule_id + if isinstance(rule_definition, str): + rule_definition = yaml.safe_load(rule_definition) + self.config = rule_definition + self.resource_types = ( + self.config["ResourceTypes"] + if isinstance(self.config["ResourceTypes"], list) + else [self.config["ResourceTypes"]] + ) + self.resource_property_types.extend(self.resource_types) + self.error_message = self.config["ErrorMessage"] + self.shortdesc = self.config.get("ShortDescription", f"{self.id}") + self.description = self.config.get("Description", f"{self.shortdesc}") + for resource_type in self.resource_types: + LOGGER.debug("%s:%s:%s initialized", self.id, resource_type, self.shortdesc) + + def _extract_path_value(self, resource_properties, path): + if path == "": + return True, resource_properties + path_parts = path.split(".") + current = resource_properties + for part in path_parts: + if part not in current: + return False, None + current = current[part] + return True, current + + def _check_condition(self, resource_properties, condition): + op = list(condition.keys())[0] + [path, expected_value] = condition[op] + op = op[4:] + LOGGER.debug("Checking condition %s %s %s", path, op, expected_value) + path_exists, value = self._extract_path_value(resource_properties, path) + # behavior is same comp. to custom rule, i.e. rule does nothing if path does not exist + # unless explicitly requiring it to be undefined + if not path_exists and op != "IS" and expected_value != "NOT_DEFINED": + return False + return Operators[op](value, expected_value) + + def _check_conditions(self, resource_properties): + if isinstance(self.config["Conditions"], list): + LOGGER.debug("Has %d conditions", len(self.config["Conditions"])) + return all( + self._check_condition(resource_properties, condition) + for condition in self.config["Conditions"] + ) + + LOGGER.debug("Has 1 conditions") + return self._check_condition(resource_properties, self.config["Conditions"]) + + def _project_value(self, value, path_split): + if (len(path_split) == 0) or (value is None): + return value + k, next_path_split = path_split[0], path_split[1:] + v = value.get(k, None) + if isinstance(v, list): + return [self._project_value(item, next_path_split) for item in v] + return self._project_value(v, next_path_split) + + def _compute_projection(self, resource_properties): + if self.config["Projection"] is not None: + return list( + flatten( + self._project_value( + resource_properties, self.config["Projection"].split(".") + ) + ) + ) + return None + + def _match_resource_properties( + self, resource_properties, property_type, path, cfn, projected_values + ): + matches = [] + validations = ( + self.config["Validations"] + if isinstance(self.config["Validations"], list) + else [self.config["Validations"]] + ) + for validation in validations: + op = list(validation.keys())[0] + [validation_path, expected_value] = validation[op] + op = op[4:] + if expected_value == "Fn::Projection": + expected_value = projected_values + try: + rule_instance = CreateRuleFromOp[op]( + self.id, + property_type, + validation_path, + expected_value, + self.error_message, + ) + rule_matches = rule_instance.match_resource_properties( + resource_properties, property_type, path, cfn + ) + matches.extend(rule_matches) + except KeyError: + matches.extend( + cfnlint.rules.custom.Operators.CreateInvalidRule(self.id, op).match( + cfn + ) + ) + return matches + + def match_resource_properties(self, resource_properties, property_type, path, cfn): + LOGGER.debug( + "Linting %s on %s::%s", self.id, property_type, str.join(".", path[:2]) + ) + if self.config.get("Conditions", None) is not None: + condition = self._check_conditions(resource_properties) + LOGGER.debug("Condition %s", condition) + if not condition: + return [] + + if self.config.get("Projection", None) is not None: + projected_values = self._compute_projection(resource_properties) + LOGGER.debug("Projected values %s", projected_values) + else: + projected_values = None + + return self._match_resource_properties( + resource_properties, property_type, path, cfn, projected_values + ) diff --git a/src/cfnlint/rules/resources/cloudfront/BehaviorOriginIdExists.py b/src/cfnlint/rules/resources/cloudfront/BehaviorOriginIdExists.py new file mode 100644 index 0000000000..8736dbca48 --- /dev/null +++ b/src/cfnlint/rules/resources/cloudfront/BehaviorOriginIdExists.py @@ -0,0 +1,21 @@ +from cfnlint.rules.generic_match_project_validate_rule import MatchProjectValidateRule + + +class BehaviorOriginIdExists(MatchProjectValidateRule): + id = "E2554" + yaml = """ + Description: 'AWS::Cloudfront::Distribution: TargetOriginId should match one of the Origins.Id' + ErrorMessage: 'TargetOriginId should match one of the Origins.Id' + ResourceTypes: AWS::CloudFront::Distribution + Projection: DistributionConfig.Origins.Id + Validations: + - Fn::IN: + - DistributionConfig.CacheBehaviors.TargetOriginId + - Fn::Projection + - Fn::IN: + - DistributionConfig.DefaultCacheBehavior.TargetOriginId + - Fn::Projection + """ + + def __init__(self): + super().__init__(BehaviorOriginIdExists.id, BehaviorOriginIdExists.yaml) diff --git a/src/cfnlint/rules/resources/cloudwatch/AlarmPeriodInSeconds.py b/src/cfnlint/rules/resources/cloudwatch/AlarmPeriodInSeconds.py new file mode 100644 index 0000000000..bf7d79b75c --- /dev/null +++ b/src/cfnlint/rules/resources/cloudwatch/AlarmPeriodInSeconds.py @@ -0,0 +1,53 @@ +from cfnlint.rules.generic_match_project_validate_rule import MatchProjectValidateRule + + +class AlarmPeriodInSecondsAWSNamespace(MatchProjectValidateRule): + id = "E2555" + yaml = """ + Description: 'AWS::CloudWatch::Alarm: Period in AWS Namespace should be at least 60' + ErrorMessage: 'Period in AWS Namespace should be 60 or multiple of 60' + ResourceTypes: AWS::CloudWatch::Alarm + Conditions: + - Fn::REGEX_MATCH: + - Namespace + - "^AWS/.*$" + Validations: + - "Fn::>=": + - Period + - 60 + - Fn::REGEX_MATCH: + - Period + - ^.*0$ + """ + + def __init__(self): + super().__init__( + AlarmPeriodInSecondsAWSNamespace.id, AlarmPeriodInSecondsAWSNamespace.yaml + ) + + +class AlarmPeriodInSecondsSmallInterval(MatchProjectValidateRule): + id = "E2556" + yaml = """ + Description: 'AWS::CloudWatch::Alarm: Period <= 60 can only be [10, 30, 60]' + ErrorMessage: 'Period <= 60 can only be [10, 30, 60]' + ResourceTypes: AWS::CloudWatch::Alarm + Conditions: + - Fn::REGEX_MATCH: + - Namespace + - ^((?!AWS).)*$ + - "Fn::<=": + - Period + - 60 + Validations: + - Fn::IN: + - Period + - - 10 + - 30 + - 60 + """ + + def __init__(self): + super().__init__( + AlarmPeriodInSecondsSmallInterval.id, AlarmPeriodInSecondsSmallInterval.yaml + ) diff --git a/src/cfnlint/rules/resources/cloudwatch/__init__.py b/src/cfnlint/rules/resources/cloudwatch/__init__.py new file mode 100644 index 0000000000..e58049dc73 --- /dev/null +++ b/src/cfnlint/rules/resources/cloudwatch/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" diff --git a/src/cfnlint/rules/resources/lmbd/RuntimeCanUseZipFile.py b/src/cfnlint/rules/resources/lmbd/RuntimeCanUseZipFile.py new file mode 100644 index 0000000000..76201704cd --- /dev/null +++ b/src/cfnlint/rules/resources/lmbd/RuntimeCanUseZipFile.py @@ -0,0 +1,21 @@ +from cfnlint.rules.generic_match_project_validate_rule import MatchProjectValidateRule + + +class RuntimeCanUseZipFile(MatchProjectValidateRule): + id = "E2553" + yaml = """ + Description: 'AWS::Lambda::Function: Zipfile can only be use for Runtime Nodejs*' + ErrorMessage: 'Zipfile can only be use for Nodejs or Python Runtime' + ResourceTypes: AWS::Lambda::Function + Conditions: + - Fn::REGEX_MATCH: + - Runtime + - "^((?!(nodejs|python)).)*$" + Validations: + - Fn::IS: + - Code.ZipFile + - NOT_DEFINED + """ + + def __init__(self): + super().__init__(RuntimeCanUseZipFile.id, RuntimeCanUseZipFile.yaml) diff --git a/test/fixtures/custom_rules/bad/custom_rule_invalid_greater_than.txt b/test/fixtures/custom_rules/bad/custom_rule_invalid_greater_than.txt index c37cb2b10a..9f12d99359 100644 --- a/test/fixtures/custom_rules/bad/custom_rule_invalid_greater_than.txt +++ b/test/fixtures/custom_rules/bad/custom_rule_invalid_greater_than.txt @@ -1,2 +1,3 @@ AWS::ElasticLoadBalancing::LoadBalancer HealthCheck.HealthyThreshold >= 5 +AWS::ElasticLoadBalancing::LoadBalancer HealthCheck.HealthyThreshold > 3 AWS::ElasticLoadBalancing::LoadBalancer HealthCheck.HealthyThreshold >= AB \ No newline at end of file diff --git a/test/fixtures/custom_rules/bad/custom_rule_invalid_less_than.txt b/test/fixtures/custom_rules/bad/custom_rule_invalid_less_than.txt index a3090844e6..58c5bbb86a 100644 --- a/test/fixtures/custom_rules/bad/custom_rule_invalid_less_than.txt +++ b/test/fixtures/custom_rules/bad/custom_rule_invalid_less_than.txt @@ -1,2 +1,3 @@ AWS::ElasticLoadBalancing::LoadBalancer HealthCheck.HealthyThreshold <= 1 +AWS::ElasticLoadBalancing::LoadBalancer HealthCheck.HealthyThreshold < 3 AWS::ElasticLoadBalancing::LoadBalancer HealthCheck.HealthyThreshold <= AB \ No newline at end of file diff --git a/test/fixtures/custom_rules/bad/custom_rule_invalid_regex.txt b/test/fixtures/custom_rules/bad/custom_rule_invalid_regex.txt new file mode 100644 index 0000000000..21773570c3 --- /dev/null +++ b/test/fixtures/custom_rules/bad/custom_rule_invalid_regex.txt @@ -0,0 +1 @@ +AWS::IAM::Role AssumeRolePolicyDocument.Version REGEX_MATCH "^202.*$" \ No newline at end of file diff --git a/test/fixtures/custom_rules/good/custom_rule_perfect.txt b/test/fixtures/custom_rules/good/custom_rule_perfect.txt index 02dbc546b3..51724b9164 100644 --- a/test/fixtures/custom_rules/good/custom_rule_perfect.txt +++ b/test/fixtures/custom_rules/good/custom_rule_perfect.txt @@ -6,5 +6,10 @@ AWS::IAM::Policy PolicyName EQUALS "root" WARN ABC AWS::IAM::Policy PolicyName IN [2012-10-16,root,2012-10-18] ERROR ABC AWS::IAM::Policy PolicyName NOT_EQUALS "user" WARN ABC AWS::IAM::Policy PolicyName NOT_IN [2012-10-16,2012-11-20,2012-10-18] ERROR ABC +AWS::EC2::Instance BlockDeviceMappings.Ebs.VolumeSize >= 20 WARN +AWS::EC2::Instance BlockDeviceMappings.Ebs.VolumeSize > 10 ERROR ABC +AWS::EC2::Instance BlockDeviceMappings.Ebs.VolumeSize <= 50 ERROR DEF +AWS::EC2::Instance BlockDeviceMappings.Ebs.VolumeSize < 40 WARN ABC +AWS::CloudFormation::Stack TemplateURL REGEX_MATCH "^https.*$" WARN ABC AWS::Lambda::Function Environment.Variables.NODE_ENV IS DEFINED AWS::Lambda::Function Environment.Variables.PRIVATE_KEY IS NOT_DEFINED \ No newline at end of file diff --git a/test/fixtures/templates/bad/resources/cloudfront/behavior_origin_id_exists.yaml b/test/fixtures/templates/bad/resources/cloudfront/behavior_origin_id_exists.yaml new file mode 100644 index 0000000000..2186347070 --- /dev/null +++ b/test/fixtures/templates/bad/resources/cloudfront/behavior_origin_id_exists.yaml @@ -0,0 +1,57 @@ +Resources: + MessedUpIds: + Properties: + DistributionConfig: + CacheBehaviors: + - AllowedMethods: + - GET + - HEAD + - OPTIONS + Compress: "true" + ForwardedValues: + QueryString: true + PathPattern: "/api/*" + TargetOriginId: proxy-to-backend-orm + ViewerProtocolPolicy: allow-all + - AllowedMethods: + - GET + - HEAD + - OPTIONS + Compress: "true" + ForwardedValues: + QueryString: true + PathPattern: "/api2/*" + TargetOriginId: i-messed-up + ViewerProtocolPolicy: allow-all + - AllowedMethods: + - GET + - HEAD + - OPTIONS + Compress: "true" + ForwardedValues: + QueryString: true + PathPattern: "/api3/*" + TargetOriginId: i-messed-up + ViewerProtocolPolicy: allow-all + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + Compress: "true" + ForwardedValues: + QueryString: true + TargetOriginId: i-messed-up + ViewerProtocolPolicy: allow-all + Enabled: true + HttpVersion: http2 + Origins: + - CustomOriginConfig: + OriginProtocolPolicy: "https-only" + DomainName: example.com + Id: proxy-to-react-project + - CustomOriginConfig: + OriginProtocolPolicy: "https-only" + DomainName: example.com + Id: proxy-to-backend-orm + Type: "AWS::CloudFront::Distribution" diff --git a/test/fixtures/templates/bad/resources/cloudwatch/alarm_period_for_namespace.yaml b/test/fixtures/templates/bad/resources/cloudwatch/alarm_period_for_namespace.yaml new file mode 100644 index 0000000000..225b668546 --- /dev/null +++ b/test/fixtures/templates/bad/resources/cloudwatch/alarm_period_for_namespace.yaml @@ -0,0 +1,41 @@ +Resources: + AWSNamespaceNumeric30: + Properties: + ComparisonOperator: GreaterThanOrEqualToThreshold + EvaluationPeriods: 2 + MetricName: HTTPCode_ELB_5XX_Count + Namespace: AWS/ApplicationELB + Period: 30 + Statistic: Sum + Threshold: 10 + Type: AWS::CloudWatch::Alarm + AWSNamespaceNumeric60: + Properties: + ComparisonOperator: GreaterThanOrEqualToThreshold + EvaluationPeriods: 2 + MetricName: HTTPCode_ELB_5XX_Count + Namespace: AWS/ApplicationELB + Period: 60 + Statistic: Sum + Threshold: 10 + Type: AWS::CloudWatch::Alarm + AWSNamespaceString30: + Properties: + ComparisonOperator: GreaterThanOrEqualToThreshold + EvaluationPeriods: 2 + MetricName: HTTPCode_ELB_5XX_Count + Namespace: AWS/ApplicationELB + Period: "30" + Statistic: Sum + Threshold: 10 + Type: AWS::CloudWatch::Alarm + MyNamespaceNumeric44: + Properties: + ComparisonOperator: GreaterThanOrEqualToThreshold + EvaluationPeriods: 2 + MetricName: HTTPCode_ELB_5XX_Count + Namespace: MyNamespace/ApplicationELB + Period: 44 + Statistic: Sum + Threshold: 10 + Type: AWS::CloudWatch::Alarm diff --git a/test/fixtures/templates/bad/resources/lambda/runtime_can_use_code_zip_file.yaml b/test/fixtures/templates/bad/resources/lambda/runtime_can_use_code_zip_file.yaml new file mode 100644 index 0000000000..315a6c20bf --- /dev/null +++ b/test/fixtures/templates/bad/resources/lambda/runtime_can_use_code_zip_file.yaml @@ -0,0 +1,14 @@ +Resources: + GoZipFile: + Properties: + Code: + ZipFile: !Sub + - | + def handler(event, context): + return "Hello World ${Region}" + - Region: !Ref "AWS::Region" + Handler: index.handler + PackageType: Zip + Runtime: go1.x + Role: arn:aws:iam::123456789012:role/lambda-role + Type: AWS::Lambda::Function diff --git a/test/fixtures/templates/good/resources/cloudfront/behavior_origin_id_exists.yaml b/test/fixtures/templates/good/resources/cloudfront/behavior_origin_id_exists.yaml new file mode 100644 index 0000000000..a147ab73ae --- /dev/null +++ b/test/fixtures/templates/good/resources/cloudfront/behavior_origin_id_exists.yaml @@ -0,0 +1,37 @@ +Resources: + ReverseProxyCloudfrontDistribution: + Properties: + DistributionConfig: + CacheBehaviors: + - AllowedMethods: + - GET + - HEAD + - OPTIONS + Compress: "true" + ForwardedValues: + QueryString: true + PathPattern: "/api/*" + TargetOriginId: proxy-to-backend-orm + ViewerProtocolPolicy: allow-all + DefaultCacheBehavior: + AllowedMethods: + - GET + - HEAD + - OPTIONS + Compress: "true" + ForwardedValues: + QueryString: true + TargetOriginId: proxy-to-react-project + ViewerProtocolPolicy: allow-all + Enabled: true + HttpVersion: http2 + Origins: + - CustomOriginConfig: + OriginProtocolPolicy: "https-only" + DomainName: example.com + Id: proxy-to-react-project + - CustomOriginConfig: + OriginProtocolPolicy: "https-only" + DomainName: "staging.mywebsite.com" + Id: proxy-to-backend-orm + Type: "AWS::CloudFront::Distribution" diff --git a/test/fixtures/templates/good/resources/cloudwatch/alarm_period_for_namespace.yaml b/test/fixtures/templates/good/resources/cloudwatch/alarm_period_for_namespace.yaml new file mode 100644 index 0000000000..4c9b68d947 --- /dev/null +++ b/test/fixtures/templates/good/resources/cloudwatch/alarm_period_for_namespace.yaml @@ -0,0 +1,31 @@ +Resources: + AWSNamespace60: + Properties: + ComparisonOperator: GreaterThanOrEqualToThreshold + EvaluationPeriods: 2 + MetricName: HTTPCode_ELB_5XX_Count + Namespace: AWS/ApplicationELB + Period: 60 + Statistic: Sum + Threshold: 10 + Type: AWS::CloudWatch::Alarm + MyNamespaceNumeric30: + Properties: + ComparisonOperator: GreaterThanOrEqualToThreshold + EvaluationPeriods: 2 + MetricName: HTTPCode_ELB_5XX_Count + Namespace: MyNamespace/ApplicationELB + Period: 30 + Statistic: Sum + Threshold: 10 + Type: AWS::CloudWatch::Alarm + MyNamespaceString10: + Properties: + ComparisonOperator: GreaterThanOrEqualToThreshold + EvaluationPeriods: 2 + MetricName: HTTPCode_ELB_5XX_Count + Namespace: MyNamespace/ApplicationELB + Period: "10" + Statistic: Sum + Threshold: 10 + Type: AWS::CloudWatch::Alarm diff --git a/test/fixtures/templates/good/resources/lambda/runtime_can_use_code_zip_file.yaml b/test/fixtures/templates/good/resources/lambda/runtime_can_use_code_zip_file.yaml new file mode 100644 index 0000000000..a7d1daddc8 --- /dev/null +++ b/test/fixtures/templates/good/resources/lambda/runtime_can_use_code_zip_file.yaml @@ -0,0 +1,27 @@ +Resources: + NodejsZipFile: + Properties: + Code: + ZipFile: !Sub + - | + def handler(event, context): + return "Hello World ${Region}" + - Region: !Ref "AWS::Region" + Handler: index.handler + PackageType: Zip + Runtime: nodejs18.x + Role: arn:aws:iam::123456789012:role/lambda-role + Type: AWS::Lambda::Function + PythonZipFile: + Properties: + Code: + ZipFile: !Sub + - | + def handler(event, context): + return "Hello World ${Region}" + - Region: !Ref "AWS::Region" + Handler: index.handler + PackageType: Zip + Runtime: python3.8 + Role: arn:aws:iam::123456789012:role/lambda-role + Type: AWS::Lambda::Function diff --git a/test/unit/module/custom_rules/test_custom_rules.py b/test/unit/module/custom_rules/test_custom_rules.py index ca4a1972ef..6dac8405e4 100644 --- a/test/unit/module/custom_rules/test_custom_rules.py +++ b/test/unit/module/custom_rules/test_custom_rules.py @@ -56,6 +56,9 @@ def setUp(self): self.invalid_less_than = ( "test/fixtures/custom_rules/bad/custom_rule_invalid_less_than.txt" ) + self.invalid_regex = ( + "test/fixtures/custom_rules/bad/custom_rule_invalid_regex.txt" + ) def test_perfect_parse(self): """Test Successful Custom_Rule Parsing""" @@ -102,6 +105,13 @@ def test_invalid_less_than(self): > -1 ) + def test_invalid_regex(self): + """Test Successful Custom_Rule Parsing""" + assert ( + self.run_tests(self.invalid_regex)[0].message.find("Regex does not match") + > -1 + ) + def test_valid_boolean_value(self): """Test Boolean values""" assert self.run_tests(self.valid_boolean) == [] diff --git a/test/unit/rules/__init__.py b/test/unit/rules/__init__.py index 70c33fd6c5..c90b3cbec0 100644 --- a/test/unit/rules/__init__.py +++ b/test/unit/rules/__init__.py @@ -2,12 +2,15 @@ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ +import json from test.testlib.testcase import BaseTestCase -import cfnlint.config +from cfnlint.formatters import JsonFormatter from cfnlint.rules import RulesCollection from cfnlint.runner import Runner +formatter = JsonFormatter() + class BaseRuleTestCase(BaseTestCase): """Used for Testing Rules""" @@ -61,3 +64,12 @@ def helper_file_negative(self, filename, err_count, regions=None): bad_runner.transform() errs = bad_runner.run() self.assertEqual(err_count, len(errs)) + + def run_file_negative(self, filename, regions=None): + """Failure test""" + regions = regions or ["us-east-1"] + template = self.load_template(filename) + bad_runner = Runner(self.collection, filename, template, regions, []) + bad_runner.transform() + errs = bad_runner.run() + return json.loads(formatter.print_matches(errs)) diff --git a/test/unit/rules/resources/cloudfront/test_behavior_origin_id_exists.py b/test/unit/rules/resources/cloudfront/test_behavior_origin_id_exists.py new file mode 100644 index 0000000000..0927939443 --- /dev/null +++ b/test/unit/rules/resources/cloudfront/test_behavior_origin_id_exists.py @@ -0,0 +1,107 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" +from test.unit.rules import BaseRuleTestCase + +from cfnlint.rules.resources.cloudfront.BehaviorOriginIdExists import ( + BehaviorOriginIdExists, +) + + +class TestMatchProjectValidateRule(BaseRuleTestCase): + """Test template mapping configurations""" + + def setUp(self): + """Setup""" + super(TestMatchProjectValidateRule, self).setUp() + for rule in [BehaviorOriginIdExists()]: + self.collection.register(rule) + + success_templates = [ + "test/fixtures/templates/good/resources/cloudfront/behavior_origin_id_exists.yaml" + ] + + def test_file_positive(self): + self.helper_file_positive() + + def test_file_negative(self): + errs = self.run_file_negative( + "test/fixtures/templates/bad/resources/cloudfront/behavior_origin_id_exists.yaml" + ) + self.assertEqual( + errs, + [ + { + "Filename": "test/fixtures/templates/bad/resources/cloudfront/behavior_origin_id_exists.yaml", + "Level": "Error", + "Location": { + "End": {"ColumnNumber": 27, "LineNumber": 24}, + "Path": [ + "Resources", + "MessedUpIds", + "Properties", + "DistributionConfig", + "CacheBehaviors", + 1, + "TargetOriginId", + ], + "Start": {"ColumnNumber": 13, "LineNumber": 24}, + }, + "Message": "TargetOriginId should match one of the Origins.Id", + "Rule": { + "Description": "AWS::Cloudfront::Distribution: TargetOriginId should match one of the Origins.Id", + "Id": "E2554", + "ShortDescription": "E2554", + "Source": "", + }, + }, + { + "Filename": "test/fixtures/templates/bad/resources/cloudfront/behavior_origin_id_exists.yaml", + "Level": "Error", + "Location": { + "End": {"ColumnNumber": 27, "LineNumber": 34}, + "Path": [ + "Resources", + "MessedUpIds", + "Properties", + "DistributionConfig", + "CacheBehaviors", + 2, + "TargetOriginId", + ], + "Start": {"ColumnNumber": 13, "LineNumber": 34}, + }, + "Message": "TargetOriginId should match one of the Origins.Id", + "Rule": { + "Description": "AWS::Cloudfront::Distribution: TargetOriginId should match one of the Origins.Id", + "Id": "E2554", + "ShortDescription": "E2554", + "Source": "", + }, + }, + { + "Filename": "test/fixtures/templates/bad/resources/cloudfront/behavior_origin_id_exists.yaml", + "Level": "Error", + "Location": { + "End": {"ColumnNumber": 25, "LineNumber": 44}, + "Path": [ + "Resources", + "MessedUpIds", + "Properties", + "DistributionConfig", + "DefaultCacheBehavior", + "TargetOriginId", + ], + "Start": {"ColumnNumber": 11, "LineNumber": 44}, + }, + "Message": "TargetOriginId should match one of the Origins.Id", + "Rule": { + "Description": "AWS::Cloudfront::Distribution: TargetOriginId should match one of the Origins.Id", + "Id": "E2554", + "ShortDescription": "E2554", + "Source": "", + }, + }, + ], + ) diff --git a/test/unit/rules/resources/cloudwatch/__init__.py b/test/unit/rules/resources/cloudwatch/__init__.py new file mode 100644 index 0000000000..e58049dc73 --- /dev/null +++ b/test/unit/rules/resources/cloudwatch/__init__.py @@ -0,0 +1,4 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" diff --git a/test/unit/rules/resources/cloudwatch/test_alarm_period_for_namespace.py b/test/unit/rules/resources/cloudwatch/test_alarm_period_for_namespace.py new file mode 100644 index 0000000000..44d2cbfde8 --- /dev/null +++ b/test/unit/rules/resources/cloudwatch/test_alarm_period_for_namespace.py @@ -0,0 +1,103 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" +from test.unit.rules import BaseRuleTestCase + +from cfnlint.rules.resources.cloudwatch.AlarmPeriodInSeconds import ( + AlarmPeriodInSecondsAWSNamespace, + AlarmPeriodInSecondsSmallInterval, +) + + +class TestMatchProjectValidateRule(BaseRuleTestCase): + """Test template mapping configurations""" + + def setUp(self): + """Setup""" + super(TestMatchProjectValidateRule, self).setUp() + for rule in [ + AlarmPeriodInSecondsAWSNamespace(), + AlarmPeriodInSecondsSmallInterval(), + ]: + self.collection.register(rule) + + success_templates = [ + "test/fixtures/templates/good/resources/cloudwatch/alarm_period_for_namespace.yaml" + ] + + def test_file_positive(self): + self.helper_file_positive() + + def test_file_negative(self): + errs = self.run_file_negative( + "test/fixtures/templates/bad/resources/cloudwatch/alarm_period_for_namespace.yaml" + ) + self.assertEqual( + errs, + [ + { + "Filename": "test/fixtures/templates/bad/resources/cloudwatch/alarm_period_for_namespace.yaml", + "Level": "Error", + "Location": { + "End": {"ColumnNumber": 13, "LineNumber": 8}, + "Path": [ + "Resources", + "AWSNamespaceNumeric30", + "Properties", + "Period", + ], + "Start": {"ColumnNumber": 7, "LineNumber": 8}, + }, + "Message": "Period in AWS Namespace should be 60 or multiple of 60", + "Rule": { + "Description": "AWS::CloudWatch::Alarm: Period in AWS Namespace should be at least 60", + "Id": "E2555", + "ShortDescription": "E2555", + "Source": "", + }, + }, + { + "Filename": "test/fixtures/templates/bad/resources/cloudwatch/alarm_period_for_namespace.yaml", + "Level": "Error", + "Location": { + "End": {"ColumnNumber": 13, "LineNumber": 28}, + "Path": [ + "Resources", + "AWSNamespaceString30", + "Properties", + "Period", + ], + "Start": {"ColumnNumber": 7, "LineNumber": 28}, + }, + "Message": "Period in AWS Namespace should be 60 or multiple of 60", + "Rule": { + "Description": "AWS::CloudWatch::Alarm: Period in AWS Namespace should be at least 60", + "Id": "E2555", + "ShortDescription": "E2555", + "Source": "", + }, + }, + { + "Filename": "test/fixtures/templates/bad/resources/cloudwatch/alarm_period_for_namespace.yaml", + "Level": "Error", + "Location": { + "End": {"ColumnNumber": 13, "LineNumber": 38}, + "Path": [ + "Resources", + "MyNamespaceNumeric44", + "Properties", + "Period", + ], + "Start": {"ColumnNumber": 7, "LineNumber": 38}, + }, + "Message": "Period <= 60 can only be [10, 30, 60]", + "Rule": { + "Description": "AWS::CloudWatch::Alarm: Period <= 60 can only be [10, 30, 60]", + "Id": "E2556", + "ShortDescription": "E2556", + "Source": "", + }, + }, + ], + ) diff --git a/test/unit/rules/resources/lmbd/test_runtime_can_use_code_zip_file.py b/test/unit/rules/resources/lmbd/test_runtime_can_use_code_zip_file.py new file mode 100644 index 0000000000..c5ccf33b89 --- /dev/null +++ b/test/unit/rules/resources/lmbd/test_runtime_can_use_code_zip_file.py @@ -0,0 +1,57 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" +from test.unit.rules import BaseRuleTestCase + +from cfnlint.rules.resources.lmbd.RuntimeCanUseZipFile import RuntimeCanUseZipFile + + +class TestMatchProjectValidateRule(BaseRuleTestCase): + """Test template mapping configurations""" + + def setUp(self): + """Setup""" + super(TestMatchProjectValidateRule, self).setUp() + for rule in [RuntimeCanUseZipFile()]: + self.collection.register(rule) + + success_templates = [ + "test/fixtures/templates/good/resources/lambda/runtime_can_use_code_zip_file.yaml" + ] + + def test_file_positive(self): + self.helper_file_positive() + + def test_file_negative(self): + errs = self.run_file_negative( + "test/fixtures/templates/bad/resources/lambda/runtime_can_use_code_zip_file.yaml" + ) + self.assertEqual( + errs, + [ + { + "Filename": "test/fixtures/templates/bad/resources/lambda/runtime_can_use_code_zip_file.yaml", + "Level": "Error", + "Location": { + "End": {"ColumnNumber": 16, "LineNumber": 5}, + "Path": [ + "Resources", + "GoZipFile", + "Properties", + "Code", + "ZipFile", + "Fn::Sub", + ], + "Start": {"ColumnNumber": 9, "LineNumber": 5}, + }, + "Message": "Zipfile can only be use for Nodejs or Python Runtime", + "Rule": { + "Description": "AWS::Lambda::Function: Zipfile can only be use for Runtime Nodejs*", + "Id": "E2553", + "ShortDescription": "E2553", + "Source": "", + }, + } + ], + )