Repository: aws-cloudformation/cfn-lint

Issue:
Only the first two Resource Duplicate are found - E0000
*cfn-lint version: (`cfn-lint 0.45.0`) Build from source: Commit: 002b3d1*

*Description of issue.*

This issue was 1st described on #1868 and fixed on #1900 

I was validating the fix and it works if there are two duplicated resources, but not if you have 3 or more duplicated resources.

I get the error `E0000 Duplicate resource found "DevRouteTableRoute2" (line 18)`  and `E0000 Duplicate resource found "DevRouteTableRoute2" (line 13)` when I try to validate a CFn template with duplicated resources. I **should get has many errors has the number of duplicated resources on the template**. The template had more than one duplication (in this case 5) , but the linter only showed the 1st and 2nd.
Please provide as much information as possible:
* Test template:
```yaml
---
AWSTemplateFormatVersion: "2010-09-09"

Resources:

  DevRouteTableRoute1:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      Blackhole: true
      DestinationCidrBlock: 10.1.1.0/24
      TransitGatewayRouteTableId: tgw-rtb-d65d85280f2c9e51c
  DevRouteTableRoute2:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      DestinationCidrBlock: 10.1.2.0/24
      TransitGatewayAttachmentId: tgw-attach-9bdf1fdfb44d9c7d7
      TransitGatewayRouteTableId: tgw-rtb-d65d85280f2c9e51c
  DevRouteTableRoute2:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      DestinationCidrBlock: 10.1.3.0/24
      TransitGatewayAttachmentId: tgw-attach-b16ea962254d4373e
      TransitGatewayRouteTableId: tgw-rtb-d65d85280f2c9e51c
  DevRouteTableRoute2:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      DestinationCidrBlock: 10.1.4.0/24
      TransitGatewayAttachmentId: tgw-attach-b16ea962254d4373e
      TransitGatewayRouteTableId: tgw-rtb-d65d85280f2c9e51c

  ProdRouteTableRoute1:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      Blackhole: true
      DestinationCidrBlock: 10.1.1.0/24
      TransitGatewayRouteTableId: tgw-rtb-5e9891f700a3af932
  ProdRouteTableRoute2:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      DestinationCidrBlock: 10.1.2.0/24
      TransitGatewayAttachmentId: tgw-attach-9bdf1fdfb44d9c7d7
      TransitGatewayRouteTableId: tgw-rtb-5e9891f700a3af932
  ProdRouteTableRoute2:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      DestinationCidrBlock: 10.1.3.0/24
      TransitGatewayAttachmentId: tgw-attach-b16ea962254d4373e
      TransitGatewayRouteTableId: tgw-rtb-5e9891f700a3af932
  ProdRouteTableRoute4:
    Type: AWS::EC2::TransitGatewayRoute
    Properties:
      DestinationCidrBlock: 10.1.4.0/24
      TransitGatewayAttachmentId: tgw-attach-b16ea962254d4373e
      TransitGatewayRouteTableId: tgw-rtb-5e9891f700a3af932
```
  * Validation Output
 ```
E0000 Duplicate resource found "DevRouteTableRoute2" (line 18)
template.yml:18:3

E0000 Duplicate resource found "DevRouteTableRoute2" (line 13)
template.yml:13:5
```
 * Expected Output
```
E0000 Duplicate resource found "ProdRouteTableRoute2" (line 43)
template.yml:43:3

E0000 Duplicate resource found "ProdRouteTableRoute2" (line 37)
template.yml:37:3

E0000 Duplicate resource found "DevRouteTableRoute2" (line 24)
template.yml:24:3

E0000 Duplicate resource found "DevRouteTableRoute2" (line 18)
template.yml:18:3

E0000 Duplicate resource found "DevRouteTableRoute2" (line 13)
template.yml:13:5
```

We should get has many errors has the number of duplicated resources on the template. This is how the linter is working for the other errors

cc/ @kddejong  


Discussion:
Yea agreed.  I'm going to leave this open for tracking. Since we handle these errors in the parser we are somewhat subject to the parser can do for passing and handling errors.  I think I can do this but it may take a little work to get it working the right way.

Resolving patch:
diff --git a/src/cfnlint/decode/cfn_json.py b/src/cfnlint/decode/cfn_json.py
--- a/src/cfnlint/decode/cfn_json.py
+++ b/src/cfnlint/decode/cfn_json.py
@@ -20,11 +20,13 @@ class DuplicateError(Exception):
     Error thrown when the template contains duplicates
     """
 
-    def __init__(self, message, mapping, key):
+    def __init__(self, message, key):
         super().__init__(message)
+        self.duplicates = []
+        self.append(key)
 
-        self.mapping = mapping
-        self.key = key
+    def append(self, key):
+        self.duplicates.append(key)
 
 
 def check_duplicates(ordered_pairs, beg_mark, end_mark):
@@ -33,11 +35,21 @@ def check_duplicates(ordered_pairs, beg_mark, end_mark):
     because a dict does not support this. It overwrites it with the last
     occurrence, which can give unexpected results
     """
+    err = None
     mapping = dict_node({}, beg_mark, end_mark)
     for key, value in ordered_pairs:
-        if key in mapping:
-            raise DuplicateError(f'"{key}"', mapping, key)
-        mapping[key] = value
+        for m_key in mapping:
+            if m_key == key:
+                if err is None:
+                    err = DuplicateError(f'"{key}"', key)
+                    err.append(m_key)
+                else:
+                    err.append(key)
+        if key not in mapping:
+            mapping[key] = value
+
+    if err is not None:
+        raise err
 
     if len(mapping) == 1:
         if 'Fn::Sub' in mapping:
@@ -90,13 +102,12 @@ def build_match(message, doc, pos, key=' '):
     )
 
 
-def build_match_from_node(message, node, key):
-
+def build_match_from_key(message, key):
     return cfnlint.rules.Match(
-        node[key].start_mark.line,
-        node[key].start_mark.column + 1,
-        node[key].end_mark.line,
-        node[key].end_mark.column + 1 + len(key),
+        key.start_mark.line,
+        key.start_mark.column + 1,
+        key.end_mark.line,
+        key.end_mark.column + 1 + len(key),
         '',
         cfnlint.rules.ParseError(),
         message=message,
@@ -438,21 +449,18 @@ def cfn_json_object(
                         result = object_pairs_hook(pairs, beg_mark, end_mark)
                         return result, end + 1
                     except DuplicateError as err:
+                        errs = []
+                        for m in err.duplicates:
+                            errs.append(
+                                build_match_from_key(
+                                    message=f'Duplicate found {err}',
+                                    key=m,
+                                )
+                            )
                         raise JSONDecodeError(
                             doc=s,
                             pos=end,
-                            errors=[
-                                build_match_from_node(
-                                    message=f'Duplicate found {err}',
-                                    node=err.mapping,
-                                    key=err.key,
-                                ),
-                                build_match(
-                                    message=f'Duplicate found {err}',
-                                    doc=s,
-                                    pos=end,
-                                ),
-                            ],
+                            errors=errs,
                         ) from err
                 pairs = {}
                 if object_hook is not None:
@@ -571,21 +579,18 @@ def cfn_json_object(
                 )
                 result = object_pairs_hook(pairs, beg_mark, end_mark)
             except DuplicateError as err:
+                errs = []
+                for m in err.duplicates:
+                    errs.append(
+                        build_match_from_key(
+                            message=f'Duplicate found {err}',
+                            key=m,
+                        )
+                    )
                 raise JSONDecodeError(
                     doc=s,
                     pos=end,
-                    errors=[
-                        build_match_from_node(
-                            message=f'Duplicate found {err}',
-                            node=err.mapping,
-                            key=err.key,
-                        ),
-                        build_match(
-                            message=f'Duplicate found {err}',
-                            doc=s,
-                            pos=end,
-                        ),
-                    ],
+                    errors=errs,
                 ) from err
             return result, end
 
diff --git a/src/cfnlint/decode/cfn_yaml.py b/src/cfnlint/decode/cfn_yaml.py
--- a/src/cfnlint/decode/cfn_yaml.py
+++ b/src/cfnlint/decode/cfn_yaml.py
@@ -85,31 +85,42 @@ def construct_yaml_map(self, node):
         # occurance, which can give unexpected results
         mapping = {}
         self.flatten_mapping(node)
+        matches = []
         for key_node, value_node in node.value:
             key = self.construct_object(key_node, False)
             value = self.construct_object(value_node, False)
 
             for key_dup in mapping:
                 if key_dup == key:
-                    raise CfnParseError(
-                        self.filename,
-                        [
+                    if not matches:
+                        matches.extend(
+                            [
+                                build_match(
+                                    filename=self.filename,
+                                    message=f'Duplicate found "{key}" (line {key_dup.start_mark.line + 1})',
+                                    line_number=key_dup.start_mark.line,
+                                    column_number=key_dup.start_mark.column,
+                                    key=key,
+                                ),
+                                build_match(
+                                    filename=self.filename,
+                                    message=f'Duplicate found "{key}" (line {key_node.start_mark.line + 1})',
+                                    line_number=key_node.start_mark.line,
+                                    column_number=key_node.start_mark.column,
+                                    key=key,
+                                ),
+                            ],
+                        )
+                    else:
+                        matches.append(
                             build_match(
                                 filename=self.filename,
-                                message=f'Duplicate resource found "{key}" (line {key_dup.start_mark.line + 1})',
-                                line_number=key_dup.start_mark.line,
-                                column_number=key_dup.start_mark.column,
-                                key=key,
-                            ),
-                            build_match(
-                                filename=self.filename,
-                                message=f'Duplicate resource found "{key}" (line {key_node.start_mark.line + 1})',
+                                message=f'Duplicate found "{key}" (line {key_node.start_mark.line + 1})',
                                 line_number=key_node.start_mark.line,
                                 column_number=key_node.start_mark.column,
                                 key=key,
                             ),
-                        ],
-                    )
+                        )
             try:
                 mapping[key] = value
             except Exception as exc:
@@ -126,6 +137,12 @@ def construct_yaml_map(self, node):
                     ],
                 ) from exc
 
+        if matches:
+            raise CfnParseError(
+                self.filename,
+                matches,
+            )
+
         (obj,) = SafeConstructor.construct_yaml_map(self, node)
 
         if len(mapping) == 1:


Test patch:
diff --git a/test/fixtures/templates/bad/duplicate.json b/test/fixtures/templates/bad/duplicate.json
--- a/test/fixtures/templates/bad/duplicate.json
+++ b/test/fixtures/templates/bad/duplicate.json
@@ -19,6 +19,12 @@
 			 "Properties" : {
 					"TopicName" : "deployed-topic"
 			 }
-		}
+		},
+		"MySNSTopic" : {
+			"Type" : "AWS::SNS::Topic",
+			"Properties" : {
+				 "TopicName" : "deployed-topic"
+			}
+	 }
 	}
 }
diff --git a/test/fixtures/templates/bad/duplicate.yaml b/test/fixtures/templates/bad/duplicate.yaml
--- a/test/fixtures/templates/bad/duplicate.yaml
+++ b/test/fixtures/templates/bad/duplicate.yaml
@@ -10,3 +10,7 @@ Resources:
     Type: AWS::SNS::Topic
     Parameters:
       TopicName: "used-topic-name"
+  mySnsTopic:
+    Type: AWS::SNS::Topic
+    Parameters:
+      TopicName: "used-topic-name"
diff --git a/test/unit/module/test_api.py b/test/unit/module/test_api.py
--- a/test/unit/module/test_api.py
+++ b/test/unit/module/test_api.py
@@ -42,7 +42,7 @@ def test_noecho_yaml_template_warnings_ignored(self):
     def test_duplicate_json_template(self):
         filename = 'test/fixtures/templates/bad/duplicate.json'
         matches = self.helper_lint_string_from_file(filename)
-        self.assertEqual(['E0000', 'E0000'], [match.rule.id for match in matches])
+        self.assertEqual(['E0000', 'E0000', 'E0000'], [match.rule.id for match in matches])
 
     def test_invalid_yaml_template(self):
         filename = 'test/fixtures/templates/bad/core/config_invalid_yaml.yaml'
diff --git a/test/unit/module/test_duplicate.py b/test/unit/module/test_duplicate.py
--- a/test/unit/module/test_duplicate.py
+++ b/test/unit/module/test_duplicate.py
@@ -42,10 +42,9 @@ def test_fail_run(self):
         filename = 'test/fixtures/templates/bad/duplicate.json'
 
         try:
-            with open(filename) as fp:
-                json.load(fp, cls=cfnlint.decode.cfn_json.CfnJSONDecoder)
+            cfnlint.decode.cfn_json.load(filename)
         except cfnlint.decode.cfn_json.JSONDecodeError as e:
-            self.assertEqual(len(e.matches), 2)
+            self.assertEqual(len(e.matches), 3)
             return
 
         assert(False)
@@ -58,7 +57,7 @@ def test_fail_yaml_run(self):
         try:
             cfnlint.decode.cfn_yaml.load(filename)
         except cfnlint.decode.cfn_yaml.CfnParseError as e:
-            self.assertEqual(len(e.matches), 2)
+            self.assertEqual(len(e.matches), 3)
             return
 
         assert(False)