""" Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ from difflib import SequenceMatcher import cfnlint.helpers from cfnlint.rules import CloudFormationLintRule, RuleMatch class Properties(CloudFormationLintRule): """Check Base Resource Configuration""" id = "E3002" shortdesc = "Resource properties are invalid" description = "Making sure that resources properties are properly configured" source_url = "https://github.com/aws-cloudformation/cfn-python-lint/blob/main/docs/cfn-resource-specification.md#properties" tags = ["resources"] def __init__(self): """Init""" super().__init__() self.cfn = {} self.resourcetypes = {} self.propertytypes = {} self.parameternames = {} self.intrinsictypes = {} def primitivetypecheck(self, value, primtype, proppath): """ Check primitive types. Only check that a primitive type is actual a primitive type: - If its JSON let it go - If its Conditions check each sub path of the condition - If its a object make sure its a valid function and function - If its a list raise an error """ matches = [] supported_functions = [ "Fn::Base64", "Fn::GetAtt", "Fn::GetAZs", "Fn::ImportValue", "Fn::Join", "Fn::Split", "Fn::FindInMap", "Fn::Select", "Ref", "Fn::If", "Fn::Contains", "Fn::Sub", "Fn::Cidr", "Fn::Transform", "Fn::Length", "Fn::ToJsonString", ] if (isinstance(value, list) and primtype != "Json") or value == { "Ref": "AWS::NotificationARNs" }: message = f'Property should be of type {primtype} not List at {"/".join(map(str, proppath))}' matches.append(RuleMatch(proppath, message)) if isinstance(value, dict) and primtype == "Json": return matches if isinstance(value, dict): if len(value) == 1: for sub_key, sub_value in value.items(): if sub_key in cfnlint.helpers.CONDITION_FUNCTIONS: # not erroring on bad Ifs but not need to account for it # so the rule doesn't error out if isinstance(sub_value, list): if len(sub_value) == 3: matches.extend( self.primitivetypecheck( sub_value[1], primtype, proppath + ["Fn::If", 1] ) ) matches.extend( self.primitivetypecheck( sub_value[2], primtype, proppath + ["Fn::If", 2] ) ) elif sub_key not in supported_functions: message = f'Property {"/".join(map(str, proppath))} has an illegal function {sub_key}' matches.append(RuleMatch(proppath, message)) else: message = f'Property is an object instead of {primtype} at {"/".join(map(str, proppath))}' matches.append(RuleMatch(proppath, message)) return matches def _check_list_for_condition( self, text, prop, parenttype, resourcename, propspec, path ): """Loop for conditions""" matches = [] if len(text) == 3: for if_i, if_v in enumerate(text[1:]): condition_path = path[:] + [if_i + 1] if isinstance(if_v, list): for index, item in enumerate(if_v): arrproppath = condition_path[:] arrproppath.append(index) matches.extend( self.propertycheck( item, propspec["ItemType"], parenttype, resourcename, arrproppath, False, ) ) elif isinstance(if_v, dict): if len(if_v) == 1: for d_k, d_v in if_v.items(): if d_k != "Ref" or d_v != "AWS::NoValue": if d_k == "Fn::GetAtt": resource_name = None if isinstance(d_v, list): resource_name = d_v[0] elif isinstance(d_v, str): resource_name = d_v.split(".")[0] if resource_name: resource_type = ( self.cfn.template.get("Resources", {}) .get(resource_name, {}) .get("Type") ) if not resource_type.startswith("Custom::"): message = "Property {0} should be of type List for resource {1} at {2}" matches.append( RuleMatch( condition_path, message.format( prop, resourcename, ( "/".join( str(x) for x in condition_path ) ), ), ) ) elif d_k == "Fn::If": matches.extend( self._check_list_for_condition( d_v, prop, parenttype, resourcename, propspec, condition_path, ) ) else: message = "Property {0} should be of type List for resource {1} at {2}" matches.append( RuleMatch( condition_path, message.format( prop, resourcename, ( "/".join( str(x) for x in condition_path ) ), ), ) ) else: message = "Property {0} should be of type List for resource {1} at {2}" matches.append( RuleMatch( condition_path, message.format( prop, resourcename, ("/".join(str(x) for x in condition_path)), ), ) ) else: message = ( "Property {0} should be of type List for resource {1} at {2}" ) matches.append( RuleMatch( condition_path, message.format( prop, resourcename, ("/".join(str(x) for x in condition_path)), ), ) ) else: message = f'Invalid !If condition specified at {"/".join(map(str, path))}' matches.append(RuleMatch(path, message)) return matches def check_list_for_condition( self, text, prop, parenttype, resourcename, propspec, path ): """Checks lists that are a dict for conditions""" matches = [] if len(text[prop]) == 1: # pylint: disable=R1702 for sub_key, sub_value in text[prop].items(): if sub_key in cfnlint.helpers.CONDITION_FUNCTIONS: matches.extend( self._check_list_for_condition( sub_value, prop, parenttype, resourcename, propspec, path + [sub_key], ) ) else: # FindInMaps can be lists of objects so skip checking those if sub_key != "Fn::FindInMap": # if its a GetAtt to a custom resource that custom resource # can return a list of objects so skip. if sub_key == "Fn::GetAtt": resource_name = None if isinstance(sub_value, list): resource_name = sub_value[0] elif isinstance(sub_value, str): resource_name = sub_value.split(".")[0] if resource_name: resource_type = ( self.cfn.template.get("Resources", {}) .get(resource_name, {}) .get("Type") ) if not ( resource_type == "AWS::CloudFormation::CustomResource" or resource_type.startswith("Custom::") ): message = f'Property is an object instead of List at {"/".join(map(str, path))}' matches.append(RuleMatch(path, message)) elif not (sub_key == "Ref" and sub_value == "AWS::NoValue"): message = f'Property is an object instead of List at {"/".join(map(str, path))}' matches.append(RuleMatch(path, message)) else: self.logger.debug( 'Too much logic to handle whats actually in the map "%s" so skipping any more validation.', sub_value, ) else: message = ( f'Property is an object instead of List at {"/".join(map(str, path))}' ) matches.append(RuleMatch(path, message)) return matches def check_exceptions(self, parenttype, proptype, text): """ Checks for exceptions to the spec - Start with handling exceptions for templated code. """ templated_exceptions = { "AWS::ApiGateway::RestApi": ["S3Location"], "AWS::Lambda::Function": ["Code"], "AWS::Lambda::LayerVersion": ["Content"], "AWS::ElasticBeanstalk::ApplicationVersion": ["SourceBundle"], "AWS::StepFunctions::StateMachine": ["S3Location"], } exceptions = templated_exceptions.get(parenttype, []) if proptype in exceptions: if isinstance(text, str): return True return False def propertycheck(self, text, proptype, parenttype, resourcename, path, root): """Check individual properties""" parameternames = self.parameternames matches = [] if root: specs = self.resourcetypes resourcetype = parenttype else: specs = self.propertytypes resourcetype = str.format("{0}.{1}", parenttype, proptype) # Handle tags if resourcetype not in specs: if proptype in specs: resourcetype = proptype else: resourcetype = str.format("{0}.{1}", parenttype, proptype) else: resourcetype = str.format("{0}.{1}", parenttype, proptype) resourcespec = specs[resourcetype].get("Properties", {}) if not resourcespec: if specs[resourcetype].get("Type") == "List": if isinstance(text, list): property_type = specs[resourcetype].get("ItemType") for index, item in enumerate(text): matches.extend( self.propertycheck( item, property_type, parenttype, resourcename, path[:] + [index], root, ) ) return matches supports_additional_properties = specs[resourcetype].get( "AdditionalProperties", False ) if text == "AWS::NoValue": return matches if not isinstance(text, dict): if not self.check_exceptions(parenttype, proptype, text): message = f'Expecting an object at {"/".join(map(str, path))}' matches.append(RuleMatch(path, message)) return matches # You can put in functions directly in place of objects as long as that is # the only thing there (conditions, select) could all (possibly) # return objects. FindInMap cannot directly return an object. len_of_text = len(text) # pylint: disable=too-many-nested-blocks for prop in text: proppath = path[:] proppath.append(prop) if prop not in resourcespec: if prop in cfnlint.helpers.CONDITION_FUNCTIONS and len_of_text == 1: cond_values = self.cfn.get_condition_values(text[prop]) for cond_value in cond_values: if isinstance(cond_value["Value"], dict): matches.extend( self.propertycheck( cond_value["Value"], proptype, parenttype, resourcename, proppath + cond_value["Path"], root, ) ) elif isinstance(cond_value["Value"], list): for index, item in enumerate(cond_value["Value"]): matches.extend( self.propertycheck( item, proptype, parenttype, resourcename, proppath + cond_value["Path"] + [index], root, ) ) elif text.is_function_returning_object(): self.logger.debug( 'Ran into function "%s". Skipping remaining checks', prop ) elif ( len(text) == 1 and prop in "Ref" and text.get(prop) == "AWS::NoValue" ): pass elif len(text) == 1 and prop in "Fn::GetAtt": getatt = text.get(prop) getatt_type = ( self.cfn.template.get("Resources", {}) .get(getatt[0], {}) .get("Type", "") ) if ( getatt_type == "AWS::CloudFormation::CustomResource" or getatt_type.startswith("Custom::") ): pass else: message = f'GetAtt must refer to a custom resource {"/".join(map(str, proppath))}' matches.append(RuleMatch(proppath, message)) elif not supports_additional_properties: close_match = False for key in resourcespec.keys(): if SequenceMatcher(a=prop, b=key).ratio() > 0.8: message = f'Invalid Property {"/".join(map(str, proppath))}. Did you mean {key}?' matches.append(RuleMatch(proppath, message)) close_match = True break if not close_match: message = f'Invalid Property {"/".join(map(str, proppath))}' matches.append(RuleMatch(proppath, message)) else: if "Type" in resourcespec[prop]: if resourcespec[prop]["Type"] == "List": if "PrimitiveItemType" not in resourcespec[prop]: if isinstance(text[prop], list): for index, item in enumerate(text[prop]): arrproppath = proppath[:] arrproppath.append(index) matches.extend( self.propertycheck( item, resourcespec[prop]["ItemType"], parenttype, resourcename, arrproppath, False, ) ) elif isinstance(text[prop], dict): # A list can be be specific as a Conditional matches.extend( self.check_list_for_condition( text, prop, parenttype, resourcename, resourcespec[prop], proppath, ) ) else: message = "Property {0} should be of type List for resource {1}" matches.append( RuleMatch( proppath, message.format(prop, resourcename) ) ) else: if isinstance(text[prop], list): primtype = resourcespec[prop]["PrimitiveItemType"] for index, item in enumerate(text[prop]): arrproppath = proppath[:] arrproppath.append(index) matches.extend( self.primitivetypecheck( item, primtype, arrproppath ) ) elif isinstance(text[prop], dict): if "Ref" in text[prop]: ref = text[prop]["Ref"] if ref == "AWS::NotificationARNs": continue if ref in parameternames: param_type = self.cfn.template["Parameters"][ ref ]["Type"] if param_type: if ( "List<" not in param_type and "