Repository: aws-cloudformation/cfn-lint

Issue:
Continued expansion of the Spec files to include Min/Max
I would like to propose a continued expansion of the spec files to include Min/Max information

- [x] Min/Max of string
- [x] Min/Max size of a JSON object (example: IAM Policy)
- [x] Min/Max size of a list
- [x] Min/Max of a number

All of these need some level of handling functions (especially conditions).  We can build out the core functionality and then it becomes easier to add additional checks for similar patterns.



Discussion:
Love the idea (of course). Maybe we need to restructure the AllowedValues (and/or spec patching definition) a bit in order to support it? 

Resolving patch:
diff --git a/src/cfnlint/rules/resources/iam/Limits.py b/src/cfnlint/rules/resources/iam/Limits.py
--- a/src/cfnlint/rules/resources/iam/Limits.py
+++ b/src/cfnlint/rules/resources/iam/Limits.py
@@ -34,37 +34,6 @@ def _serialize_date(self, obj):
             return obj.isoformat()
         raise TypeError('Object of type {} is not JSON serializable'.format(obj.__class__.__name__))
 
-    def check_managed_policy_arns(self, properties, path, cfn):
-        """Check ManagedPolicyArns is within limits"""
-        matches = []
-
-        if 'ManagedPolicyArns' not in properties:
-            return matches
-
-        property_sets = cfn.get_object_without_conditions(properties)
-        for property_set in property_sets:
-            managed_policies = property_set.get('Object').get('ManagedPolicyArns')
-            if isinstance(managed_policies, list):
-                if len(managed_policies) > 10:
-                    if property_set['Scenario'] is None:
-                        matches.append(
-                            RuleMatch(
-                                path + ['ManagedPolicyArns'],
-                                'IAM resources cannot have more than 10 ManagedPolicyArns',
-                            )
-                        )
-                    else:
-                        scenario_text = ' and '.join(['when condition "%s" is %s' % (k, v) for (k, v) in property_set['Scenario'].items()])
-                        message = 'IAM resources cannot have more than 10 ManagedPolicyArns when {0}'
-                        matches.append(
-                            RuleMatch(
-                                path + ['ManagedPolicyArns'],
-                                message.format(scenario_text),
-                            )
-                        )
-
-        return matches
-
     def check_instance_profile_roles(self, properties, path, cfn):
         """Check InstanceProfile.Roles is within limits"""
         matches = []
@@ -96,37 +65,6 @@ def check_instance_profile_roles(self, properties, path, cfn):
 
         return matches
 
-    def check_user_groups(self, properties, path, cfn):
-        """Check User.Groups is within limits"""
-        matches = []
-
-        if 'Groups' not in properties:
-            return matches
-
-        property_sets = cfn.get_object_without_conditions(properties)
-        for property_set in property_sets:
-            groups = property_set.get('Object').get('Groups')
-            if isinstance(groups, list):
-                if len(groups) > 10:
-                    if property_set['Scenario'] is None:
-                        matches.append(
-                            RuleMatch(
-                                path + ['Groups'],
-                                'User can be a member of maximum 10 groups',
-                            )
-                        )
-                    else:
-                        scenario_text = ' and '.join(['when condition "%s" is %s' % (k, v) for (k, v) in property_set['Scenario'].items()])
-                        message = 'User can be a member of maximum 10 groups when {0}'
-                        matches.append(
-                            RuleMatch(
-                                path + ['Groups'],
-                                message.format(scenario_text),
-                            )
-                        )
-
-        return matches
-
     def check_role_assume_role_policy_document(self, properties, path, _):
         """Check Role.AssumeRolePolicyDocument is within limits"""
         matches = []
@@ -149,18 +87,13 @@ def match(self, cfn):
         matches = []
         types = {
             'AWS::IAM::User': [
-                self.check_managed_policy_arns,
-                self.check_user_groups,
             ],
             'AWS::IAM::Group': [
-                self.check_managed_policy_arns,
             ],
             'AWS::IAM::Role': [
-                self.check_managed_policy_arns,
                 self.check_role_assume_role_policy_document,
             ],
             'AWS::IAM::InstanceProfile': [
-                self.check_instance_profile_roles,
             ],
         }
 
diff --git a/src/cfnlint/rules/resources/properties/ListSize.py b/src/cfnlint/rules/resources/properties/ListSize.py
new file mode 100644
--- /dev/null
+++ b/src/cfnlint/rules/resources/properties/ListSize.py
@@ -0,0 +1,107 @@
+"""
+  Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy of this
+  software and associated documentation files (the "Software"), to deal in the Software
+  without restriction, including without limitation the rights to use, copy, modify,
+  merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+  permit persons to whom the Software is furnished to do so.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+  INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+  PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+  HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+"""
+from cfnlint import CloudFormationLintRule
+from cfnlint import RuleMatch
+
+from cfnlint.helpers import RESOURCE_SPECS
+
+
+class ListSize(CloudFormationLintRule):
+    """Check if List has a size within the limit"""
+    id = 'E3032'
+    shortdesc = 'Check if a list has between min and max number of values specified'
+    description = 'Check lists for the number of items in the list to validate they are between the minimum and maximum'
+    source_url = 'https://github.com/awslabs/cfn-python-lint/blob/master/docs/cfn-resource-specification.md#allowedpattern'
+    tags = ['resources', 'property', 'list', 'size']
+
+    def initialize(self, cfn):
+        """Initialize the rule"""
+        for resource_type_spec in RESOURCE_SPECS.get(cfn.regions[0]).get('ResourceTypes'):
+            self.resource_property_types.append(resource_type_spec)
+        for property_type_spec in RESOURCE_SPECS.get(cfn.regions[0]).get('PropertyTypes'):
+            self.resource_sub_property_types.append(property_type_spec)
+
+    def check_value(self, properties, path, property_name, cfn, value_specs):
+        """Check Value"""
+        matches = []
+
+        # Get the Min and Max for a List
+        list_min = value_specs.get('ListMin')
+        list_max = value_specs.get('ListMax')
+
+        if list_min is not None and list_max is not None:
+            property_sets = cfn.get_object_without_conditions(properties)
+            for property_set in property_sets:
+                prop = property_set.get('Object').get(property_name)
+                if isinstance(prop, list):
+                    if not list_min <= len(prop) <= list_max:
+                        if property_set['Scenario'] is None:
+                            message = '{0} has to have between {1} and {2} items specified'
+                            matches.append(
+                                RuleMatch(
+                                    path + [property_name],
+                                    message.format(property_name, list_min, list_max),
+                                )
+                            )
+                        else:
+                            scenario_text = ' and '.join(['when condition "%s" is %s' % (k, v) for (k, v) in property_set['Scenario'].items()])
+                            message = '{0} has to have between {1} and {2} items specified when {0}'
+                            matches.append(
+                                RuleMatch(
+                                    path + [property_name],
+                                    message.format(property_name, list_min, list_max, scenario_text),
+                                )
+                            )
+
+        return matches
+
+    def check(self, cfn, properties, specs, path):
+        """Check itself"""
+        matches = []
+        for p_value, p_path in properties.items_safe(path[:]):
+            for prop in p_value:
+                if prop in specs:
+                    value = specs.get(prop).get('Value', {})
+                    if value:
+                        value_type = value.get('ValueType', '')
+                        property_type = specs.get(prop).get('Type')
+                        if property_type == 'List':
+                            matches.extend(
+                                self.check_value(
+                                    p_value, p_path, prop, cfn,
+                                    RESOURCE_SPECS.get(cfn.regions[0]).get('ValueTypes').get(value_type, {})
+                                )
+                            )
+        return matches
+
+    def match_resource_sub_properties(self, properties, property_type, path, cfn):
+        """Match for sub properties"""
+        matches = list()
+
+        specs = RESOURCE_SPECS.get(cfn.regions[0]).get('PropertyTypes').get(property_type, {}).get('Properties', {})
+        matches.extend(self.check(cfn, properties, specs, path))
+
+        return matches
+
+    def match_resource_properties(self, properties, resource_type, path, cfn):
+        """Check CloudFormation Properties"""
+        matches = list()
+
+        specs = RESOURCE_SPECS.get(cfn.regions[0]).get('ResourceTypes').get(resource_type, {}).get('Properties', {})
+        matches.extend(self.check(cfn, properties, specs, path))
+
+        return matches


Test patch:
diff --git a/test/integration/test_patched_specs.py b/test/integration/test_patched_specs.py
--- a/test/integration/test_patched_specs.py
+++ b/test/integration/test_patched_specs.py
@@ -112,8 +112,11 @@ def test_sub_properties(self):
     def test_property_value_types(self):
         """Test Property Value Types"""
         for v_name, v_values in self.spec.get('ValueTypes').items():
+            list_count = 0
             for p_name, p_values in v_values.items():
-                self.assertIn(p_name, ['Ref', 'GetAtt', 'AllowedValues', 'AllowedPattern', 'AllowedPatternRegex'])
+                self.assertIn(p_name, ['Ref', 'GetAtt', 'AllowedValues', 'AllowedPattern', 'AllowedPatternRegex', 'ListMin', 'ListMax'])
+                if p_name in ['ListMin', 'ListMax']:
+                    list_count += 1
                 if p_name == 'Ref':
                     self.assertIsInstance(p_values, dict, 'ValueTypes: %s, Type: %s' % (v_name, p_name))
                     for r_name, r_value in p_values.items():
@@ -138,6 +141,7 @@ def test_property_value_types(self):
                     self.assertIsInstance(p_values, list)
                     for l_value in p_values:
                         self.assertIsInstance(l_value, (six.string_types, six.integer_types), 'ValueTypes: %s, Type: %s' % (v_name, p_name))
+            self.assertIn(list_count, [0, 2], 'Both ListMin and ListMax must be specified')
 
     def test_parameter_types(self):
         """Test Parameter Types"""
diff --git a/test/rules/resources/iam/test_iam_limits.py b/test/rules/resources/iam/test_iam_limits.py
--- a/test/rules/resources/iam/test_iam_limits.py
+++ b/test/rules/resources/iam/test_iam_limits.py
@@ -29,18 +29,6 @@ def test_file_positive(self):
         """Test Positive"""
         self.helper_file_positive()
 
-    def test_managedpolicyarns(self):
-        """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_iam_managedpolicyarns.yaml', 3)
-
-    def test_user_groups(self):
-        """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_iam_user_groups.yaml', 1)
-
-    def test_instance_profile_roles(self):
-        """Test failure"""
-        self.helper_file_negative('test/fixtures/templates/bad/resources_iam_instanceprofile_roles.yaml', 1)
-
     def test_role_assume_role_policy_document(self):
         """Test failure"""
         self.helper_file_negative('test/fixtures/templates/bad/resources_iam_role_assume_role_policy_document.yaml', 1)
diff --git a/test/rules/resources/properties/test_list_size.py b/test/rules/resources/properties/test_list_size.py
new file mode 100644
--- /dev/null
+++ b/test/rules/resources/properties/test_list_size.py
@@ -0,0 +1,38 @@
+"""
+  Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy of this
+  software and associated documentation files (the "Software"), to deal in the Software
+  without restriction, including without limitation the rights to use, copy, modify,
+  merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+  permit persons to whom the Software is furnished to do so.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+  INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+  PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+  HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+  OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+"""
+from cfnlint.rules.resources.properties.ListSize import ListSize  # pylint: disable=E0401
+from ... import BaseRuleTestCase
+
+
+class TestListSize(BaseRuleTestCase):
+    """Test List Size Property Configuration"""
+    def setUp(self):
+        """Setup"""
+        super(TestListSize, self).setUp()
+        self.collection.register(ListSize())
+
+    def test_file_positive(self):
+        """Test Positive"""
+        self.helper_file_positive()
+
+    def test_file_negative_iam_managed_policies(self):
+        """Test failure"""
+        self.helper_file_negative('test/fixtures/templates/bad/resources_iam_managedpolicyarns.yaml', 3)
+
+    def test_file_negative_iam_user_groups(self):
+        """Test failure"""
+        self.helper_file_negative('test/fixtures/templates/bad/resources_iam_user_groups.yaml', 1)