"""
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 "<List" not in param_type
and "<CommaDelimitedList"
not in param_type
and not param_type
== "CommaDelimitedList"
):
message = (
"Property {0} should be of type List or Parameter should "
"be a list for resource {1}"
)
matches.append(
RuleMatch(
proppath,
message.format(
prop, resourcename
),
)
)
else:
message = "Property {0} should be of type List for resource {1}"
matches.append(
RuleMatch(
proppath,
message.format(prop, resourcename),
)
)
else:
if len(text[prop]) == 1:
for k in text[prop].keys():
def_intrinsic_type = (
self.intrinsictypes.get(k, {})
)
if def_intrinsic_type:
if (
len(
def_intrinsic_type.get(
"ReturnTypes"
)
)
== 1
and def_intrinsic_type.get(
"ReturnTypes"
)[0]
== "Singular"
):
message = "Property {0} is using {1} when a List is needed for resource {2}"
matches.append(
RuleMatch(
proppath,
message.format(
prop, k, resourcename
),
)
)
else:
message = "Property {0} should be of type List for resource {1}"
matches.append(
RuleMatch(
proppath, message.format(prop, resourcename)
)
)
else:
if resourcespec[prop]["Type"] not in ["Map"]:
matches.extend(
self.propertycheck(
text[prop],
resourcespec[prop]["Type"],
parenttype,
resourcename,
proppath,
False,
)
)
elif "PrimitiveType" in resourcespec[prop]:
primtype = resourcespec[prop]["PrimitiveType"]
matches.extend(
self.primitivetypecheck(text[prop], primtype, proppath)
)
return matches
def match(self, cfn):
"""Check CloudFormation Properties"""
matches = []
self.cfn = cfn
resourcespecs = cfnlint.helpers.RESOURCE_SPECS[cfn.regions[0]]
self.resourcetypes = resourcespecs["ResourceTypes"]
self.propertytypes = resourcespecs["PropertyTypes"]
self.intrinsictypes = resourcespecs["IntrinsicTypes"]
self.parameternames = self.cfn.get_parameter_names()
for resourcename, resourcevalue in cfn.get_resources().items():
if "Properties" in resourcevalue and "Type" in resourcevalue:
resourcetype = resourcevalue.get("Type", None)
if (
resourcetype.startswith("Custom::")
and resourcetype not in self.resourcetypes
):
resourcetype = "AWS::CloudFormation::CustomResource"
if resourcetype in self.resourcetypes:
path = ["Resources", resourcename, "Properties"]
matches.extend(
self.propertycheck(
resourcevalue.get("Properties", {}),
"",
resourcetype,
resourcename,
path,
True,
)
)
return matches