""" Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 """ import copy from functools import reduce # pylint: disable=redefined-builtin import regex as re from cfnlint.rules import CloudFormationLintRule, RuleMatch class SubNeeded(CloudFormationLintRule): """Check if a substitution string exists without a substitution function""" id = "E1029" shortdesc = "Sub is required if a variable is used in a string" description = "If a substitution variable exists in a string but isn't wrapped with the Fn::Sub function the deployment will fail." source_url = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html" tags = ["functions", "sub"] exceptions = ["TemplateBody"] def __init__(self): """Init""" super().__init__() self.config_definition = {"custom_excludes": {"default": "", "type": "string"}} self.configure() self.subParameterRegex = re.compile(r"(\$\{[A-Za-z0-9_:\.]+\})") def _match_values(self, cfnelem, path): """Recursively search for values matching the searchRegex""" values = [] if isinstance(cfnelem, dict): for key in cfnelem: pathprop = path[:] pathprop.append(key) values.extend(self._match_values(cfnelem[key], pathprop)) elif isinstance(cfnelem, list): for index, item in enumerate(cfnelem): pathprop = path[:] pathprop.append(index) values.extend(self._match_values(item, pathprop)) else: # Leaf node if isinstance(cfnelem, str): # and re.match(searchRegex, cfnelem): for variable in re.findall(self.subParameterRegex, cfnelem): values.append(path + [variable]) return values def match_values(self, cfn): """ Search for values in all parts of the templates that match the searchRegex """ results = [] results.extend(self._match_values(cfn.template, [])) # Globals are removed during a transform. They need to be checked manually results.extend(self._match_values(cfn.template.get("Globals", {}), [])) return results def _api_exceptions(self, value): """Key value exceptions""" parameter_search = re.compile(r"^\$\{stageVariables\..*\}$") return re.match(parameter_search, value) def _variable_custom_excluded(self, value): """User-defined exceptions for variables, anywhere in the file""" custom_excludes = self.config["custom_excludes"] if custom_excludes: custom_search = re.compile(custom_excludes) return re.match(custom_search, value) return False def match(self, cfn): matches = [] refs = cfn.get_valid_refs() getatts = cfn.get_valid_getatts() # Get a list of paths to every leaf node string containing at least one ${parameter} parameter_string_paths = self.match_values(cfn) # We want to search all of the paths to check if each one contains an 'Fn::Sub' for parameter_string_path in parameter_string_paths: # Get variable var = parameter_string_path[-1] # Step Function State Machine has a Definition Substitution that allows usage of special variables outside of a !Sub # https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-stepfunctions-statemachine-definitionsubstitutions.html if "DefinitionString" in parameter_string_path: modified_parameter_string_path = copy.copy(parameter_string_path) index = parameter_string_path.index("DefinitionString") modified_parameter_string_path[index] = "DefinitionSubstitutions" modified_parameter_string_path = modified_parameter_string_path[ : index + 1 ] modified_parameter_string_path.append(var[2:-1]) if reduce( lambda c, k: c.get(k, {}), modified_parameter_string_path, cfn.template, ): continue # Exclude variables that match custom exclude filters, if configured # (for third-party tools that pre-process templates before uploading them to AWS) if self._variable_custom_excluded(var): continue # Exclude literals (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html) if var.startswith("${!"): continue var_stripped = var[2:-1].strip() # If we didn't find an 'Fn::Sub' it means a string containing a ${parameter} may not be evaluated correctly if ( not "Fn::Sub" in parameter_string_path and parameter_string_path[-2] not in self.exceptions ): if ( var_stripped in refs or var_stripped in getatts ) or "DefinitionString" in parameter_string_path: # Remove the last item (the variable) to prevent multiple errors on 1 line errors path = parameter_string_path[:-1] message = f'Found an embedded parameter "{var}" outside of an "Fn::Sub" at {"/".join(map(str, path))}' matches.append(RuleMatch(path, message)) return matches