Repository: aws-cloudformation/cfn-lint

Issue:
E1029 - False positive with multiline YAML string
*cfn-lint version: (`cfn-lint --version`)*
0.35.0

*Description of issue.*
Using ${iot:Connection.Thing.ThingName} inside an AWS::IoT::Policy PolicyDocument is flagged as an embedded parameter outside of Fn::Sub.

Freshly installed cfn-lint this morning. I'm using Python 2.7 and VS Code

* Sample Template:
```
AWSTemplateFormatVersion: '2010-09-09'

Resources:
  TestPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument: >
        {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": "iot:Connect",
              "Resource": "arn:aws:iot:eu-central-1:123456789012:client/${iot:Connection.Thing.ThingName}"
            }
          ]
        }
      PolicyName: ConnectWithThingName
```

In the second example in their developer guide on AWS IoT they use the exact same policy document. https://docs.aws.amazon.com/iot/latest/developerguide/connect-policy.html

* Feature request:
Like with IAM policies, IoT policies shouldn't have variables flagged as embedded parameters.

E1029 - False positive with multiline YAML string
*cfn-lint version: (`cfn-lint --version`)*
0.35.0

*Description of issue.*
Using ${iot:Connection.Thing.ThingName} inside an AWS::IoT::Policy PolicyDocument is flagged as an embedded parameter outside of Fn::Sub.

Freshly installed cfn-lint this morning. I'm using Python 2.7 and VS Code

* Sample Template:
```
AWSTemplateFormatVersion: '2010-09-09'

Resources:
  TestPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument: >
        {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Action": "iot:Connect",
              "Resource": "arn:aws:iot:eu-central-1:123456789012:client/${iot:Connection.Thing.ThingName}"
            }
          ]
        }
      PolicyName: ConnectWithThingName
```

In the second example in their developer guide on AWS IoT they use the exact same policy document. https://docs.aws.amazon.com/iot/latest/developerguide/connect-policy.html

* Feature request:
Like with IAM policies, IoT policies shouldn't have variables flagged as embedded parameters.



Discussion:
**Seems like the [multiline YAML string](https://yaml-multiline.info/) isn't handled by this:**

https://github.com/aws-cloudformation/cfn-python-lint/blob/766f47072f8b93f13be3129a59c06e5cee5f2bc3/src/cfnlint/rules/functions/SubNeeded.py#L33-L38

https://github.com/aws-cloudformation/cfn-python-lint/blob/766f47072f8b93f13be3129a59c06e5cee5f2bc3/src/cfnlint/rules/functions/SubNeeded.py#L121-L123

---

**Can workaround with [resource-level ignores](https://github.com/aws-cloudformation/cfn-python-lint#resource-based-metadata) until this is fixed:**

```yaml
  Resource:
    Type: AWS::IoT::Policy
    Metadata:
      cfn-lint:
        config:
          ignore_checks:
          - E1029
```

**or switch from using the [multiline YAML string](https://yaml-multiline.info/) (may cause [resource replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement)):**

```yaml
Resources:
  TestPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument:
          Version: "2012-10-17"
          Statement:
              Effect: "Allow"
              Action: "iot:Connect"
              Resource: "arn:aws:iot:eu-central-1:123456789012:client/${iot:Connection.Thing.ThingName}"
      PolicyName: ConnectWithThingName
```

[`AWS::IoT::Policy`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-policy.html#aws-resource-iot-policy--examples)
I ended up fixing the issue by using Fn::Sub, since I didn't like the hardcoded `region` and `account ID`:

```
Resources:
  TestPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument:
        !Sub >
            {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": "iot:Connect",
                  "Resource": "arn:aws:iot:${AWS::Region}:${AWS::AccountId}:client/${!iot:Connection.Thing.ThingName}"
                }
              ]
            }
      PolicyName: ConnectWithThingName
```
With the exclamation mark inside ${}, `iot:Connection.Thing.ThingName` will not be replaced. 
**Seems like the [multiline YAML string](https://yaml-multiline.info/) isn't handled by this:**

https://github.com/aws-cloudformation/cfn-python-lint/blob/766f47072f8b93f13be3129a59c06e5cee5f2bc3/src/cfnlint/rules/functions/SubNeeded.py#L33-L38

https://github.com/aws-cloudformation/cfn-python-lint/blob/766f47072f8b93f13be3129a59c06e5cee5f2bc3/src/cfnlint/rules/functions/SubNeeded.py#L121-L123

---

**Can workaround with [resource-level ignores](https://github.com/aws-cloudformation/cfn-python-lint#resource-based-metadata) until this is fixed:**

```yaml
  Resource:
    Type: AWS::IoT::Policy
    Metadata:
      cfn-lint:
        config:
          ignore_checks:
          - E1029
```

**or switch from using the [multiline YAML string](https://yaml-multiline.info/) (may cause [resource replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement)):**

```yaml
Resources:
  TestPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument:
          Version: "2012-10-17"
          Statement:
              Effect: "Allow"
              Action: "iot:Connect"
              Resource: "arn:aws:iot:eu-central-1:123456789012:client/${iot:Connection.Thing.ThingName}"
      PolicyName: ConnectWithThingName
```

[`AWS::IoT::Policy`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iot-policy.html#aws-resource-iot-policy--examples)
I ended up fixing the issue by using Fn::Sub, since I didn't like the hardcoded `region` and `account ID`:

```
Resources:
  TestPolicy:
    Type: AWS::IoT::Policy
    Properties:
      PolicyDocument:
        !Sub >
            {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": "iot:Connect",
                  "Resource": "arn:aws:iot:${AWS::Region}:${AWS::AccountId}:client/${!iot:Connection.Thing.ThingName}"
                }
              ]
            }
      PolicyName: ConnectWithThingName
```
With the exclamation mark inside ${}, `iot:Connection.Thing.ThingName` will not be replaced. 

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
@@ -2,57 +2,36 @@
 Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
 SPDX-License-Identifier: MIT-0
 """
-from functools import reduce  # pylint: disable=redefined-builtin
 import re
 import six
+import cfnlint.helpers
 from cfnlint.rules import CloudFormationLintRule
 from cfnlint.rules import RuleMatch
+from cfnlint.data import AdditionalSpecs
 
 
 class SubNeeded(CloudFormationLintRule):
-    """Check if a substitution string exists without a substitution function"""
+    """
+        Check if a substitution string exists without a substitution function
+
+        Found a false-positive or false-negative? All configuration for this rule
+        is contained in `src/cfnlint/data/AdditionalSpecs/SubNeededExcludes.json`
+        so you can add new entries to fix false-positives or amend existing
+        entries for false-negatives.
+    """
     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']
 
-    # Free-form text properties to exclude from this rule
-    excludes = ['UserData', 'ZipFile', 'Condition', 'AWS::CloudFormation::Init',
-                'CloudWatchAlarmDefinition', 'TopicRulePayload', 'BuildSpec',
-                'RequestMappingTemplate', 'LogFormat', 'TemplateBody', 'ResponseMappingTemplate']
-    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__()
+        excludes_config = cfnlint.helpers.load_resource(AdditionalSpecs, 'SubNeededExcludes.json')
+
+        self.excludes = excludes_config
+
         self.config_definition = {
             'custom_excludes': {
                 'default': '',
@@ -62,19 +41,20 @@ def __init__(self):
         self.configure()
         self.subParameterRegex = re.compile(r'(\$\{[A-Za-z0-9_:\.]+\})')
 
-    def _match_values(self, cfnelem, path):
+
+    def _match_values_recursive(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))
+                values.extend(self._match_values_recursive(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))
+                values.extend(self._match_values_recursive(item, pathprop))
         else:
             # Leaf node
             if isinstance(cfnelem, six.string_types):  # and re.match(searchRegex, cfnelem):
@@ -83,20 +63,17 @@ def _match_values(self, cfnelem, path):
 
         return values
 
-    def match_values(self, cfn):
+
+    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, []))
+        results.extend(self._match_values_recursive(cfn.template, []))
         # Globals are removed during a transform.  They need to be checked manually
-        results.extend(self._match_values(cfn.template.get('Globals', {}), []))
+        results.extend(self._match_values_recursive(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 """
@@ -106,56 +83,125 @@ def _variable_custom_excluded(self, value):
             return re.match(custom_search, value)
         return False
 
+
+    def _excluded_by_additional_resource_specs(self, variable, path, cfn):
+        """
+            Should the variable be excluded due to the resource
+            configuration in the AdditionalSpecs file
+            'SubNeededExcludes.json'?
+        """
+        resource_name = path[0]
+        resource_type = cfn.template.get('Resources').get(resource_name).get('Type')
+
+        if resource_type in self.excludes['ResourceTypes']:
+            return self._excluded_by_additional_resource_specs_recursive(variable, path, path[2:], self.excludes['ResourceTypes'][resource_type], cfn)
+
+        return False
+
+
+    def _excluded_by_criteria(self, variable, full_path, exclusions, cfn):
+        """
+           Run the exclusion logic, and because pylint too-many-return-statements complains
+        """
+        # Have we excluded all variables for this resource?
+        if 'ExcludeAll' in exclusions and exclusions['ExcludeAll']:
+            return True
+
+        # Have we excluded the specific variable
+        if 'ExcludeValues' in exclusions:
+            if variable in exclusions['ExcludeValues']:
+                return True
+
+        if 'ExcludeRegex' in exclusions:
+            exclude_regex = re.compile(r'^{}$'.format(exclusions['ExcludeRegex']))
+            if re.match(exclude_regex, variable):
+                return True
+
+        if 'ExcludeBasedOnProperties' in exclusions:
+            for exclude_based_on_property in exclusions['ExcludeBasedOnProperties']:
+                exclude_based_on_property_path = exclude_based_on_property.split('.')[1:]
+
+                current_property = cfn.template.get('Resources').get(full_path[0]).get('Properties')
+
+                while exclude_based_on_property_path and current_property:
+                    current_property = current_property.get(exclude_based_on_property_path[0])
+                    exclude_based_on_property_path = exclude_based_on_property_path[1:]
+
+                if current_property and variable in current_property:
+                    return True
+
+        return False
+
+
+    def _excluded_by_additional_resource_specs_recursive(self, variable, full_path, path, exclusions, cfn):
+        """
+            Should the variable be excluded due to the resource
+            configuration in the AdditionalSpecs file
+            'SubNeededExcludes.json'?
+        """
+        # Make sure the path is well-defined
+        if not path:
+            return False
+
+        # Should we exclude the variable based on the current criteria defined in the specs?
+        if self._excluded_by_criteria(variable, full_path, exclusions, cfn):
+            return True
+
+        # Recursively check the exclusion critera based on the property tree
+        sub_fields = ['Metadata', 'Properties']
+
+        for sub_field in sub_fields:
+            if sub_field in exclusions:
+
+                # Traverse over any arrays, path should never end with an integer
+                while isinstance(path[0], int):
+                    path = path[1:]
+
+                # Recurse
+                if path[0] in exclusions[sub_field]:
+                    if self._excluded_by_additional_resource_specs_recursive(variable, full_path, path[1:], exclusions[sub_field][path[0]], cfn):
+                        return True
+
+        return False
+
+
+    def _excluded_by_additional_specs(self, variable, path, cfn):
+        """
+            Should the variable be excluded due to the configuration
+            in the AdditionalSpecs file 'SubNeededExcludes.json'?
+        """
+        if path[0] == 'Resources':
+            # Check exclusion at the resource level first
+            return self._excluded_by_additional_resource_specs(variable[2:-1], path[1:], cfn)
+
+        return False
+
+
     def match(self, cfn):
         matches = []
 
         # Get a list of paths to every leaf node string containing at least one ${parameter}
-        parameter_string_paths = self.match_values(cfn)
+        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
-
-            # 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
-                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])
-                if reduce(lambda c, k: c.get(k, {}), modified_parameter_string_path, cfn.template):
-                    continue
+            # The variable to be substituted
+            variable = parameter_string_path[-1]
 
             # 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):
                 continue
 
-            # Exclude literals (https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-sub.html)
-            if variable.startswith('${!'):
+            if self._excluded_by_additional_specs(variable, parameter_string_path, cfn):
                 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:
+                if step == 'Fn::Sub':
                     found_sub = True
 
             # If we didn't find an 'Fn::Sub' it means a string containing a ${parameter} may not be evaluated correctly


Test patch:
diff --git a/test/fixtures/schemas/SubNeededExcludes.json b/test/fixtures/schemas/SubNeededExcludes.json
new file mode 100644
--- /dev/null
+++ b/test/fixtures/schemas/SubNeededExcludes.json
@@ -0,0 +1,66 @@
+{
+    "$schema": "http://json-schema.org/draft-04/schema#",
+    "title": "Product",
+    "description": "Exclusion critera for the Fn::Sub Needed rule (E1029)",
+    "type": "object",
+    "additionalProperties": false,
+    "properties": {
+        "ResourceTypes": {
+            "$ref": "#/definitions/resourcetypes"
+        }
+    },
+    "definitions": {
+        "resourcetypes": {
+            "patternProperties": {
+                "^[a-zA-Z0-9]+::[a-zA-Z0-9]+::[a-zA-Z0-9]+$": {
+                    "$ref": "#/definitions/resource"
+                }
+            },
+            "additionalProperties": false,
+            "uniqueItems": true
+        },
+        "resource": {
+            "type": "object",
+            "properties": {
+                "ExcludeRegex": {
+                    "description": "A regex to match variables to exclude from the rule",
+                    "type": "string"
+                },
+                "ExcludeAll": {
+                    "description": "Exclude all variables from the rule",
+                    "type": "boolean"
+                },
+                "ExcludeValues": {
+                    "description": "Exclude any variable matching these values from the rule",
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    },
+                    "uniqueItems": true
+                },
+                "ExcludeBasedOnProperties": {
+                    "description": "Exclude any variables defined in these properties from the rule",
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    },
+                    "uniqueItems": true
+                },
+                "Metadata": {
+                    "description": "Exclusion critera for metadata associated with a resource",
+                    "$ref": "#/definitions/resource"
+                },
+                "Properties": {
+                    "description": "Exclusion critera for properties associated with a resource",
+                    "$ref": "#/definitions/resource"
+                }
+            },
+            "additionalProperties": {
+                "$ref": "#/definitions/resource"
+            }
+        }
+    },
+    "required": [
+        "ResourceTypes"
+    ]
+}
\ No newline at end of file
diff --git a/test/fixtures/templates/bad/functions/sub_needed.yaml b/test/fixtures/templates/bad/functions/sub_needed.yaml
--- a/test/fixtures/templates/bad/functions/sub_needed.yaml
+++ b/test/fixtures/templates/bad/functions/sub_needed.yaml
@@ -31,6 +31,53 @@ Resources:
     Type: AWS::SNS::Topic
     Properties:
       TopicName: "${aws:username}-topic"
+  MultilineYAMLTestPolicy:
+    Type: AWS::IoT::Policy
+    Properties:
+      PolicyDocument: >
+        {
+          "Version": "2012-10-17",
+          "Statement": [
+            {
+              "Effect": "Allow",
+              "Action": "iot:Connect",
+              "Resource": "arn:aws:iot:eu-central-1:123456789012:${badstringsubstitution}/${iot:Connection.Thing.ThingName}"
+            }
+          ]
+        }
+      PolicyName: ConnectWithThingName
+  GreetingRequest:
+    Type: AWS::ApiGateway::Method
+    Properties:
+      AuthorizationType: NONE
+      HttpMethod: GET
+      Integration:
+        Type: AWS
+        IntegrationHttpMethod: POST
+        Uri:
+          Fn::Join:
+          - ""
+          - - "arn:aws:apigateway:"
+            - !Ref "AWS::Region"
+            - ":lambda:path/2015-03-31/functions/"
+            - !GetAtt GreetingLambda.Arn
+            - ":${notAstageVariable.LambdaAlias}"
+            - "/invocations"
+        IntegrationResponses:
+        - StatusCode: '200'
+        RequestTemplates:
+          application/json:
+            Fn::Join:
+            - ""
+            - - "{"
+              - "  \"name\": \"$input.params('name')\""
+              - "}"
+      RequestParameters:
+        method.request.querystring.name: false
+      ResourceId: !Ref GreetingResource
+      RestApiId: !Ref GreetingApi
+      MethodResponses:
+      - StatusCode: '200'
 
   TestBadStateMachine1:
     Type: AWS::StepFunctions::StateMachine
diff --git a/test/fixtures/templates/good/functions/sub_needed.yaml b/test/fixtures/templates/good/functions/sub_needed.yaml
--- a/test/fixtures/templates/good/functions/sub_needed.yaml
+++ b/test/fixtures/templates/good/functions/sub_needed.yaml
@@ -134,6 +134,21 @@ Resources:
                   - !Ref AWS::Region
                   - !Ref AWS::AccountId
                   - thing/${iot:Connection.Thing.ThingName}*
+  MultilineYAMLTestPolicy:
+    Type: AWS::IoT::Policy
+    Properties:
+      PolicyDocument: >
+        {
+          "Version": "2012-10-17",
+          "Statement": [
+            {
+              "Effect": "Allow",
+              "Action": "iot:Connect",
+              "Resource": "arn:aws:iot:eu-central-1:123456789012:client/${iot:Connection.Thing.ThingName}"
+            }
+          ]
+        }
+      PolicyName: ConnectWithThingName
   TestGoodStateMachine1:
     Type: AWS::StepFunctions::StateMachine
     Properties:
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
@@ -36,4 +36,4 @@ def test_template_config(self):
 
     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', 10)
diff --git a/test/unit/specs/test_required_based_on_value.py b/test/unit/specs/test_required_based_on_value.py
--- a/test/unit/specs/test_required_based_on_value.py
+++ b/test/unit/specs/test_required_based_on_value.py
@@ -6,6 +6,7 @@
 import test.fixtures.schemas
 from jsonschema import validate
 from test.testlib.testcase import BaseTestCase
+from cfnlint.data import AdditionalSpecs
 import cfnlint.helpers
 
 
diff --git a/test/unit/specs/test_sub_needed_excludes_schema.py b/test/unit/specs/test_sub_needed_excludes_schema.py
new file mode 100644
--- /dev/null
+++ b/test/unit/specs/test_sub_needed_excludes_schema.py
@@ -0,0 +1,23 @@
+"""
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: MIT-0
+"""
+import logging
+import test.fixtures.schemas
+from jsonschema import validate
+from test.testlib.testcase import BaseTestCase
+from cfnlint.data import AdditionalSpecs
+import cfnlint.helpers
+
+
+LOGGER = logging.getLogger('cfnlint.maintenance')
+LOGGER.addHandler(logging.NullHandler())
+
+
+class TestSubNeededExcludesSchema(BaseTestCase):
+
+
+    def test_validate_additional_specs_schema(self):
+        spec = cfnlint.helpers.load_resource(cfnlint.data.AdditionalSpecs, 'SubNeededExcludes.json')
+        schema = cfnlint.helpers.load_resource(test.fixtures.schemas, 'SubNeededExcludes.json')
+        validate(instance=spec, schema=schema)