Repository: aws-cloudformation/cfn-lint

Issue:
Invalid FindInMap does not get picked up
Got the following mapping:
```
Mappings:
  CertificateMap:
    us-east-1:
      Arn: arn:aws:acm:us-east-1:<account>:certificate/<id>

Resources
  AppAlbListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      # ...
      Certificates:
        - CertificateArn:
            Fn::FindInMap: [CertificateMap, !Ref 'AWS::Region', id]
```

As you can see, `Fn::FindInMap` is referencing the `id` key instead of `Arn`. We should add a rule to fail in such a scenario.


Discussion:
Agreed.  This would be a great rule.  Thanks.
Agreed! @hoshsadiq -- if you feel like tackling this let us know! There are some tools in the docs/ directory to help bootstrap you on writing rules, and you can reach out to any of us if you get stuck.

We've also got a Slack channel I can invite you too for real-time discussion.
Yes please, would be good. I do have a few questions.
Awesome -- send me your email either to cmmeyer@amazon.com or as a DM on twitter (@chuckm) and I'll get you invited.
I had ideas to create this rule a few times but I always paused when you realized you can use Ref for the MapName.  But what we should have done is just punt in that scenario.  Have some check for common scenarios of the map being a string is better than nothing.  
Yeah I realised that. It was one of the reason I wanted to join the Slack, to see if you guys already have a way of resolving refs as best as possible, but haven't had a chance to ask it yet as I've been without internet everytime I've started looking at this.

Resolving patch:
diff --git a/src/cfnlint/rules/functions/FindInMap.py b/src/cfnlint/rules/functions/FindInMap.py
--- a/src/cfnlint/rules/functions/FindInMap.py
+++ b/src/cfnlint/rules/functions/FindInMap.py
@@ -59,6 +59,62 @@ def check_dict(self, obj, tree):
 
         return matches
 
+    def map_name(self, map_name, mappings, tree):
+        """ Check the map name """
+        matches = []
+        if isinstance(map_name, (six.string_types, dict)):
+            if isinstance(map_name, dict):
+                matches.extend(self.check_dict(map_name, tree[:] + [0]))
+            else:
+                if map_name not in mappings:
+                    message = 'Map Name {0} doesnt exist for {0}'
+                    matches.append(RuleMatch(
+                        tree[:] + [0], message.format(map_name, '/'.join(map(str, tree)))))
+        else:
+            message = 'Map Name should be a {0}, or string at {1}'
+            matches.append(RuleMatch(
+                tree[:] + [0],
+                message.format(
+                    ', '.join(map(str, self.supported_functions)),
+                    '/'.join(map(str, tree)))))
+
+        return matches
+
+    def first_key(self, first_key, tree):
+        """ Check the validity of the first key """
+        matches = []
+        if isinstance(first_key, (six.string_types, int)):
+            return matches
+        if isinstance(first_key, (dict)):
+            matches.extend(self.check_dict(first_key, tree[:] + [1]))
+        else:
+            message = 'FindInMap first key should be a {0}, string, or int at {1}'
+            matches.append(RuleMatch(
+                tree[:] + [1],
+                message.format(
+                    ', '.join(map(str, self.supported_functions)),
+                    '/'.join(map(str, tree)))))
+
+        return matches
+
+    def second_key(self, second_key, tree):
+        """ Check the validity of the second key """
+        matches = []
+        if isinstance(second_key, (six.string_types, int)):
+            return matches
+
+        if isinstance(second_key, (dict)):
+            matches.extend(self.check_dict(second_key, tree[:] + [2]))
+        else:
+            message = 'FindInMap second key should be a {0}, string, or int at {1}'
+            matches.append(RuleMatch(
+                tree[:] + [2],
+                message.format(
+                    ', '.join(map(str, self.supported_functions)),
+                    '/'.join(map(str, tree)))))
+
+        return matches
+
     def match(self, cfn):
         """Check CloudFormation GetAtt"""
 
@@ -79,43 +135,9 @@ def match(self, cfn):
                 first_key = map_obj[1]
                 second_key = map_obj[2]
 
-                if isinstance(map_name, (six.string_types, six.text_type, dict)):
-                    if isinstance(map_name, dict):
-                        matches.extend(self.check_dict(map_name, tree[:] + [0]))
-                    else:
-                        if map_name not in mappings:
-                            message = 'Map Name {0} doesnt exist for {0}'
-                            matches.append(RuleMatch(
-                                tree[:] + [0], message.format(map_name, '/'.join(map(str, tree)))))
-                else:
-                    message = 'Map Name should be a {0}, or string at {1}'
-                    matches.append(RuleMatch(
-                        tree[:] + [0],
-                        message.format(
-                            ', '.join(map(str, self.supported_functions)),
-                            '/'.join(map(str, tree)))))
-
-                if isinstance(first_key, (six.text_type, six.string_types, dict, int)):
-                    if isinstance(first_key, dict):
-                        matches.extend(self.check_dict(first_key, tree[:] + [1]))
-                else:
-                    message = 'Map first key should be a {0}, string, or int at {1}'
-                    matches.append(RuleMatch(
-                        tree[:] + [1],
-                        message.format(
-                            ', '.join(map(str, self.supported_functions)),
-                            '/'.join(map(str, tree)))))
-
-                if isinstance(second_key, (six.text_type, six.string_types, dict, int)):
-                    if isinstance(second_key, dict):
-                        matches.extend(self.check_dict(second_key, tree[:] + [2]))
-                else:
-                    message = 'Map second key should be a {0}, string, or int at {1}'
-                    matches.append(RuleMatch(
-                        tree[:] + [2],
-                        message.format(
-                            ', '.join(map(str, self.supported_functions)),
-                            '/'.join(tree))))
+                matches.extend(self.map_name(map_name, mappings, tree))
+                matches.extend(self.first_key(first_key, tree))
+                matches.extend(self.second_key(second_key, tree))
 
             else:
                 message = 'FindInMap is a list with 3 values for {0}'
diff --git a/src/cfnlint/rules/functions/FindInMapKeys.py b/src/cfnlint/rules/functions/FindInMapKeys.py
new file mode 100644
--- /dev/null
+++ b/src/cfnlint/rules/functions/FindInMapKeys.py
@@ -0,0 +1,78 @@
+"""
+  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.
+"""
+import six
+from cfnlint import CloudFormationLintRule
+from cfnlint import RuleMatch
+
+
+class FindInMapKeys(CloudFormationLintRule):
+    """Check if FindInMap values are correct"""
+    id = 'W1011'
+    shortdesc = 'FindInMap keys exist in the map'
+    description = 'Checks the keys in a FindInMap to make sure they exist. ' \
+                  'Check only if the Map Name is a string and if the key is a string.'
+    source_url = 'https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-findinmap.html'
+    tags = ['functions', 'findinmap']
+
+    def check_keys(self, map_name, keys, mappings, tree):
+        """ Check the validity of the first key """
+        matches = []
+        first_key = keys[0]
+        second_key = keys[1]
+        if isinstance(second_key, (six.string_types, int)):
+            if isinstance(map_name, (six.string_types)):
+                mapping = mappings.get(map_name)
+                if mapping:
+                    if isinstance(first_key, (six.string_types, int)):
+                        if isinstance(map_name, (six.string_types)):
+                            if not mapping.get(first_key):
+                                message = 'FindInMap first key "{0}" doesn\'t exist in map "{1}" at {3}'
+                                matches.append(RuleMatch(
+                                    tree[:] + [1],
+                                    message.format(first_key, map_name, first_key, '/'.join(map(str, tree)))))
+                        if mapping.get(first_key):
+                            # Don't double error if they first key doesn't exist
+                            if not mapping.get(first_key, {}).get(second_key):
+                                message = 'FindInMap second key "{0}" doesn\'t exist in map "{1}" under "{2}" at {3}'
+                                matches.append(RuleMatch(
+                                    tree[:] + [2],
+                                    message.format(second_key, map_name, first_key, '/'.join(map(str, tree)))))
+                    else:
+                        for key, value in mapping.items():
+                            if not value.get(second_key):
+                                message = 'FindInMap second key "{0}" doesn\'t exist in map "{1}" under "{2}" at {3}'
+                                matches.append(RuleMatch(
+                                    tree[:] + [2],
+                                    message.format(second_key, map_name, key, '/'.join(map(str, tree)))))
+
+        return matches
+
+    def match(self, cfn):
+        """Check CloudFormation GetAtt"""
+
+        matches = []
+
+        findinmaps = cfn.search_deep_keys('Fn::FindInMap')
+        mappings = cfn.get_mappings()
+        for findinmap in findinmaps:
+            tree = findinmap[:-1]
+            map_obj = findinmap[-1]
+
+            if len(map_obj) == 3:
+                matches.extend(self.check_keys(map_obj[0], map_obj[1:], mappings, tree))
+
+        return matches


Test patch:
diff --git a/test/fixtures/templates/bad/functions/findinmap_keys.yaml b/test/fixtures/templates/bad/functions/findinmap_keys.yaml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/bad/functions/findinmap_keys.yaml
@@ -0,0 +1,25 @@
+---
+AWSTemplateFormatVersion: "2010-09-09"
+Mappings:
+  CertificateMap:
+    us-east-1:
+      Arn: arn:aws:acm:us-east-1:<account>:certificate/<id>
+Resources:
+  AppAlbListener1:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      Certificates:
+        - CertificateArn:  # Fails when the first key is bad
+            Fn::FindInMap: [CertificateMap, 'us-west-2', Arn]
+  AppAlbListener2:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      Certificates:
+        - CertificateArn:  # Fails when the 2nd key doesn't exist and first key is string
+            Fn::FindInMap: [CertificateMap, 'us-west-2', id]
+  AppAlbListener3:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      Certificates:
+        - CertificateArn:  # Fails when the 2nd key doesn't exist and the first key is a dict
+            Fn::FindInMap: [CertificateMap, !Ref 'AWS::Region', id]
diff --git a/test/fixtures/templates/good/functions/findinmap_keys.yaml b/test/fixtures/templates/good/functions/findinmap_keys.yaml
new file mode 100644
--- /dev/null
+++ b/test/fixtures/templates/good/functions/findinmap_keys.yaml
@@ -0,0 +1,34 @@
+---
+AWSTemplateFormatVersion: "2010-09-09"
+Mappings:
+  CertificateMap:
+    us-east-1:
+      Arn: arn:aws:acm:us-east-1:<account>:certificate/<id>
+    2:
+      Arn: arn:aws:acm:us-east-1:<account>:certificate/<id>
+Resources:
+  AppAlbListener:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      Certificates:
+        - CertificateArn:
+            Fn::FindInMap: [CertificateMap, !Ref 'AWS::Region', !Ref 'AWS::Region']
+            # Doesn't fail when a Ref is used
+  AppAlbListener1:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      Certificates:
+        - CertificateArn:  # Doesn't fail with hardcoded string
+            Fn::FindInMap: [CertificateMap, 'us-east-1', Arn]
+  AppAlbListener2:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      Certificates:
+        - CertificateArn:  # Doesn't fail with map is dynamic
+            Fn::FindInMap: [!Ref 'AWS::Region', 'us-west-2', id]
+  AppAlbListener3:
+    Type: AWS::ElasticLoadBalancingV2::Listener
+    Properties:
+      Certificates:
+        - CertificateArn:  # Doesn't fail with integer
+            Fn::FindInMap: [!Ref 'AWS::Region', 2, id]
diff --git a/test/rules/functions/test_find_in_map_keys.py b/test/rules/functions/test_find_in_map_keys.py
new file mode 100644
--- /dev/null
+++ b/test/rules/functions/test_find_in_map_keys.py
@@ -0,0 +1,37 @@
+"""
+  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.functions.FindInMapKeys import FindInMapKeys  # pylint: disable=E0401
+from .. import BaseRuleTestCase
+
+
+class TestRulesFindInMapKeys(BaseRuleTestCase):
+    """Test Find In Map Keys Rule """
+    def setUp(self):
+        """Setup"""
+        super(TestRulesFindInMapKeys, self).setUp()
+        self.collection.register(FindInMapKeys())
+        self.success_templates = [
+            'test/fixtures/templates/good/functions/findinmap_keys.yaml',
+        ]
+
+    def test_file_positive(self):
+        """Test Positive"""
+        self.helper_file_positive()
+
+    def test_file_negative(self):
+        """Test failure"""
+        self.helper_file_negative('test/fixtures/templates/bad/functions/findinmap_keys.yaml', 3)