Repository: aws-cloudformation/cfn-lint

Issue:
E1029 - false positive with X.509 Certificate AWS IoT Core policy variables
cfn-lint version: (cfn-lint 0.49.2)

I got a false positive on X.509 Certificate AWS IoT Core policy variables.

I think that `cfn-lint` may not support X.509 Certificate AWS IoT Core policy variables.

https://docs.aws.amazon.com/iot/latest/developerguide/cert-policy-variables.html

As noted above, X.509 certificate attributes can contain one or more values, but `resource_excludes` does not support specification by regex. This problem is a similar case in the past, `${iot:Connection.Thing.Attributes[attributeName]}` could not be supported (see also #765).

However, I think that just supporting the first value covers many cases.

If you are okay with this idea, I can contribute.

Do you have any comments?

**Output:**
```shell-session
❯ cfn-lint iot-policy.cfn.yml
E1019 Parameter iot:Certificate.Subject.CommonName for Fn::Sub not found at Resources/IoTPolicy/Properties/PolicyDocument/Statement/1/Resource/0/Fn::Sub
iot-policy.cfn.yml:18:13
```

**For reproduction:**
```yaml
AWSTemplateFormatVersion: 2010-09-09

Resources:
  IoTPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
              - iot:Connect
            Resource:
              - arn:aws:iot:us-east-1:123456789012:client/${iot:Connection.Thing.ThingName}
          - Effect: Allow
            Action:
              - iot:Publish
            Resource:
              - !Sub "arn:aws:iot:us-east-1:123456789012:topic/my/topic/${iot:Certificate.Subject.CommonName}"
```



Discussion:
@xeres when I use this template to create a stack i get `Template error: instance of Fn::Sub references invalid resource attribute iot:Certificate.Subject.CommonName` which is similar to the error we provide.

You will want to make sure you use the literal string like this `!Sub "arn:aws:iot:us-east-1:123456789012:topic/my/topic/${!iot:Certificate.Subject.CommonName}"`

This will make the error remove but it will bring back W1020 which says the sub isn't needed.




Oh, I'm sorry.
The reproduction template is wrong.

https://docs.aws.amazon.com/iot/latest/developerguide/certificate-policy-examples.html
A similar as the third case, but I want to use with `!Sub` for `${AWS::Partition}`, `${AWS::Region}` and `${AWS::AccountId}`.

```yaml
AWSTemplateFormatVersion: 2010-09-09

Resources:
  IoTPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Action:
              - iot:Connect
            Resource:
              - arn:aws:iot:us-east-1:123456789012:client/${iot:Connection.Thing.ThingName}
          - Effect: Allow
            Action:
              - iot:Publish
            Resource:
              - !Join
                - ""
                - - !Sub "arn:${AWS::Partition}:iot:${AWS::Region}:${AWS::AccountId}:topic/my/topic/"
                  - "${iot:Certificate.Subject.CommonName}"
```

Resolving patch:
diff --git a/src/cfnlint/rules/functions/SubNeeded.py b/src/cfnlint/rules/functions/SubNeeded.py
--- a/src/cfnlint/rules/functions/SubNeeded.py
+++ b/src/cfnlint/rules/functions/SubNeeded.py
@@ -4,6 +4,7 @@
 """
 from functools import reduce  # pylint: disable=redefined-builtin
 import re
+import copy
 import six
 from cfnlint.rules import CloudFormationLintRule
 from cfnlint.rules import RuleMatch
@@ -17,40 +18,6 @@ class SubNeeded(CloudFormationLintRule):
     source_url = 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html'
     tags = ['functions', 'sub']
 
-    # Free-form text properties to exclude from this rule
-    excludes = ['UserData', 'ZipFile', 'Condition', 'AWS::CloudFormation::Init',
-                'CloudWatchAlarmDefinition', 'TopicRulePayload', 'BuildSpec',
-                'RequestMappingTemplate', 'LogFormat', 'TemplateBody', 'ResponseMappingTemplate',
-                'RouteSelectionExpression']
-    api_excludes = ['Uri', 'Body', 'ConnectionId']
-
-
-    # IAM Policy has special variables that don't require !Sub, Check for these
-    # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_variables.html
-    # https://docs.aws.amazon.com/iot/latest/developerguide/basic-policy-variables.html
-    # https://docs.aws.amazon.com/iot/latest/developerguide/thing-policy-variables.html
-    # https://docs.aws.amazon.com/transfer/latest/userguide/users.html#users-policies-scope-down
-    # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_iam-condition-keys.html
-    resource_excludes = ['${aws:CurrentTime}', '${aws:EpochTime}',
-                         '${aws:TokenIssueTime}', '${aws:principaltype}',
-                         '${aws:SecureTransport}', '${aws:SourceIp}',
-                         '${aws:UserAgent}', '${aws:userid}',
-                         '${aws:username}', '${ec2:SourceInstanceARN}',
-                         '${iot:Connection.Thing.ThingName}',
-                         '${iot:Connection.Thing.ThingTypeName}',
-                         '${iot:Connection.Thing.IsAttached}',
-                         '${iot:ClientId}', '${transfer:HomeBucket}',
-                         '${transfer:HomeDirectory}', '${transfer:HomeFolder}',
-                         '${transfer:UserName}', '${redshift:DbUser}',
-                         '${cognito-identity.amazonaws.com:aud}',
-                         '${cognito-identity.amazonaws.com:sub}',
-                         '${cognito-identity.amazonaws.com:amr}']
-
-    # https://docs.aws.amazon.com/redshift/latest/mgmt/redshift-iam-access-control-identity-based.html
-    condition_excludes = [
-        '${redshift:DbUser}',
-    ]
-
     def __init__(self):
         """Init"""
         super(SubNeeded, self).__init__()
@@ -110,61 +77,46 @@ def _variable_custom_excluded(self, value):
     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:
-            if parameter_string_path[0] in ['Parameters']:
-                continue
-            # Exclude the special IAM variables
-            variable = parameter_string_path[-1]
-
-            if 'Resource' in parameter_string_path:
-                if variable in self.resource_excludes:
-                    continue
-            if 'NotResource' in parameter_string_path:
-                if variable in self.resource_excludes:
-                    continue
-            if 'Condition' in parameter_string_path:
-                if variable in self.condition_excludes:
-                    continue
+            # 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 = 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(variable[2:-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(variable):
+            if self._variable_custom_excluded(var):
                 continue
 
             # Exclude literals (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html)
-            if variable.startswith('${!'):
+            if var.startswith('${!'):
                 continue
 
-            found_sub = False
-            # Does the path contain an 'Fn::Sub'?
-            for step in parameter_string_path:
-                if step in self.api_excludes:
-                    if self._api_exceptions(parameter_string_path[-1]):
-                        found_sub = True
-                elif step == 'Fn::Sub' or step in self.excludes:
-                    found_sub = True
+            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 found_sub:
-                # Remove the last item (the variable) to prevent multiple errors on 1 line errors
-                path = parameter_string_path[:-1]
-                message = 'Found an embedded parameter "{}" outside of an "Fn::Sub" at {}'.format(
-                    variable, '/'.join(map(str, path)))
-                matches.append(RuleMatch(path, message))
+            if not 'Fn::Sub' in parameter_string_path:
+                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 = 'Found an embedded parameter "{}" outside of an "Fn::Sub" at {}'.format(
+                        var, '/'.join(map(str, path)))
+                    matches.append(RuleMatch(path, message))
 
         return matches
diff --git a/src/cfnlint/template.py b/src/cfnlint/template.py
--- a/src/cfnlint/template.py
+++ b/src/cfnlint/template.py
@@ -133,7 +133,6 @@ def get_parameter_names(self):
         return results
 
     def get_valid_refs(self):
-        LOGGER.debug('Get all valid REFs from template...')
         results = cfnlint.helpers.RegexDict()
         parameters = self.template.get('Parameters', {})
         if parameters:
@@ -166,7 +165,6 @@ def get_valid_refs(self):
         return results
 
     def get_valid_getatts(self):
-        LOGGER.debug('Get valid GetAtts from template...')
         resourcetypes = cfnlint.helpers.RESOURCE_SPECS['us-east-1'].get('ResourceTypes')
         results = {}
         resources = self.template.get('Resources', {})


Test patch:
diff --git a/test/fixtures/templates/good/functions/sub_needed_custom_excludes.yaml b/test/fixtures/templates/good/functions/sub_needed_custom_excludes.yaml
--- a/test/fixtures/templates/good/functions/sub_needed_custom_excludes.yaml
+++ b/test/fixtures/templates/good/functions/sub_needed_custom_excludes.yaml
@@ -1,10 +1,13 @@
 AWSTemplateFormatVersion: "2010-09-09"
 Description: Template containing third-party preprocessor-like variables to test custom ignore filters
+Parameters:
+  Stage:
+    Type: String
 Resources:
   TestRole:
     Type: AWS::IAM::Role
     Properties:
-      RoleName: TestRole-${self:provider.stage}
+      RoleName: TestRole-${Stage}
       AssumeRolePolicyDocument:
         Version: '2012-10-17'
         Statement:
diff --git a/test/unit/rules/functions/test_sub_needed.py b/test/unit/rules/functions/test_sub_needed.py
--- a/test/unit/rules/functions/test_sub_needed.py
+++ b/test/unit/rules/functions/test_sub_needed.py
@@ -27,13 +27,13 @@ def test_template_config(self):
         """Test custom excludes configuration"""
         self.helper_file_rule_config(
             'test/fixtures/templates/good/functions/sub_needed_custom_excludes.yaml',
-            {'custom_excludes': '^\\$\\{self.+\\}$'}, 0
+            {'custom_excludes': '^\\$\\{Stage\\}$'}, 0
         )
         self.helper_file_rule_config(
             'test/fixtures/templates/good/functions/sub_needed_custom_excludes.yaml',
-            {}, 4
+            {}, 1
         )
 
     def test_file_negative(self):
         """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/functions/sub_needed.yaml', 8)
+        self.helper_file_negative('test/fixtures/templates/bad/functions/sub_needed.yaml', 7)