Repository: aws-cloudformation/cfn-lint

Issue:
TypeError: unhashable type: 'dict_node' due to Fn::Sub
### CloudFormation Lint Version

0.87.2

### What operating system are you using?

MacOS 14.4.1

### Describe the bug

When running cfn-lint on our CloudFormation Template, we get the following error that seems caused by lines 263-265 and line 85: 

```
Traceback (most recent call last):
  File "/opt/homebrew/bin/cfn-lint", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/__main__.py", line 40, in main
    matches = list(cfnlint.core.get_matches(filenames, args))
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/core.py", line 179, in get_matches
    matches = run_cli(
              ^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/core.py", line 84, in run_cli
    return run_checks(filename, template, rules, regions, mandatory_rules)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/core.py", line 354, in run_checks
    errors.extend(runner.transform())
                  ^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/runner.py", line 35, in transform
    matches = self.cfn.transform()
              ^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/template/template.py", line 170, in transform
    return transform.transform(self)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/template/transforms/transform.py", line 54, in transform
    cfn.graph = Graph(cfn)
                ^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/graph.py", line 92, in __init__
    self._add_getatts(cfn)
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/graph.py", line 189, in _add_getatts
    and (self._is_resource(cfn, value[0]))
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/graph.py", line 264, in _is_resource
    return cfn.template.get("Resources", {}).get(identifier, {})
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/opt/homebrew/Cellar/cfn-lint/0.87.2/libexec/lib/python3.12/site-packages/cfnlint/decode/node.py", line 126, in get
    return super().get(key, default)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unhashable type: 'dict_node'
```

### Expected behavior

We expected cfn-lint to return without any errors since our CloudFormation template deploys successfully in the console.

### Reproduction template

```
{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Transform": "AWS::LanguageExtensions",
  "Description": "Creates an AWS budget with two notification thresholds for chosen service (EC2 or Lambda). A notification email will be sent when the first budget threshold is met. When the budget amount is reached, you will receive a notification and an IAM DENY policy will be applied to prevent the creation of new resources of chosen service type. ",
  "Parameters": {
    "Name": {
      "Description": "The name you want for the budget.",
      "Type": "String"
    },
    "Service": {
      "Type": "String",
      "Default": "EC2",
      "AllowedValues": ["EC2", "Lambda"],
      "Description": "Which service should this Budget target and track costs for? Choose either EC2 or Lambda."
    },
    "Amount": {
      "Description": "The monthly budget amount that will trigger the budget action.",
      "Type": "Number"
    },
    "Currency": {
      "Description": "The currency of your budget. Default is USD. For a list of other currencies offered, please reference: https://docs.aws.amazon.com/marketplace/latest/buyerguide/buyer-paying-for-products.html#supported-currencies",
      "Type": "String",
      "Default": "USD"
    },
    "WarningThreshold": {
      "Description": "You'll receive a notification when your budget reaches this dollar amount. Enter a number that is less than the total budget amount.",
      "Type": "Number"
    },
    "Email": {
      "Description": "The email address to receive notifications.",
      "Type": "String",
      "AllowedPattern": "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"
    },
    "IAMRoles": {
      "Description": "List the IAM Role names that the IAM policy should apply to with a comma between each Role.",
      "Type": "CommaDelimitedList"
    },
    "IAMRoleARNs": {
      "Description": "List the associated ARNs for the IAM Roles entered above.",
      "Type": "CommaDelimitedList",
      "AllowedPattern": "^arn:aws:iam::\\d{12}:role/.+"
    }
  },
  "Metadata": {
    "AWS::CloudFormation::Interface": {
      "ParameterGroups": [
        {
          "Parameters": [
            "Name",
            "Service",
            "Amount",
            "Currency",
            "WarningThreshold",
            "SecondThreshold",
            "Email",
            "IAMRoles",
            "IAMRoleARNs"
          ]
        }
      ]
    }
  },
  "Conditions": {
    "isEC2": {
      "Fn::Equals": [
        {
          "Ref": "Service"
        },
        "EC2"
      ]
    },
    "isLambda": {
      "Fn::Equals": [
        {
          "Ref": "Service"
        },
        "Lambda"
      ]
    }
  },
  "Resources": {
    "BudgetActionExecutionRole": {
      "Type": "AWS::IAM::Role",
      "Properties": {
        "RoleName": { "Fn::Sub": "${Service}BudgetActionExecutionRole" },
        "AssumeRolePolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Allow",
              "Principal": {
                "Service": "budgets.amazonaws.com"
              },
              "Action": "sts:AssumeRole"
            }
          ]
        },
        "Policies": [
          {
            "PolicyName": "BudgetActionExecutionRolePolicy",
            "PolicyDocument": {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Effect": "Allow",
                  "Action": [
                    "iam:AttachGroupPolicy",
                    "iam:AttachRolePolicy",
                    "iam:AttachUserPolicy",
                    "iam:DetachGroupPolicy",
                    "iam:DetachRolePolicy",
                    "iam:DetachUserPolicy"
                  ],
                  "Resource": {
                    "Ref": "IAMRoleARNs"
                  }
                }
              ]
            }
          }
        ]
      }
    },
    "EC2IamActionDefinitionPolicy": {
      "Condition": "isEC2",
      "Type": "AWS::IAM::ManagedPolicy",
      "Properties": {
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Deny",
              "Action": [
                "ec2:RequestSpotInstances",
                "ec2:StartInstances",
                "ec2:RunScheduledInstances",
                "ec2:CreateInstanceConnectEndpoint",
                "ec2:RunInstances",
                "ec2:CreateCapacityReservation",
                "ec2:PurchaseCapacityBlock",
                "ec2:PurchaseHostReservation",
                "ec2:PurchaseReservedInstancesOffering"
              ],
              "Resource": "*"
            }
          ]
        }
      }
    },
    "LambdaIamActionDefinitionPolicy": {
      "Condition": "isLambda",
      "Type": "AWS::IAM::ManagedPolicy",
      "Properties": {
        "PolicyDocument": {
          "Version": "2012-10-17",
          "Statement": [
            {
              "Effect": "Deny",
              "Action": [
                "lambda:CreateFunction",
                "lambda:CreateFunctionUrlConfig",
                "lambda:InvokeAsync",
                "lambda:InvokeFunction",
                "lambda:InvokeFunctionUrl",
                "lambda:PutFunctionConcurrency",
                "lambda:PutProvisionedConcurrencyConfig",
                "lambda:PutFunctionEventInvokeConfig",
                "lambda:CreateEventSourceMapping",
                "lambda:PutRuntimeManagementConfig"
              ],
              "Resource": "*"
            }
          ]
        }
      }
    },
    "Budget": {
      "Type": "AWS::Budgets::Budget",
      "Properties": {
        "Budget": {
          "BudgetName": {
            "Ref": "Name"
          },
          "BudgetLimit": {
            "Amount": {
              "Ref": "Amount"
            },
            "Unit": {
              "Ref": "Currency"
            }
          },
          "CostFilters": {
            "Service": [{ "Fn::If" : [
              "isEC2",
              "Amazon Elastic Compute Cloud - Compute",
                {"Fn::If" : [
                  "isLambda",
                  "AWS Lambda",
                  ""
                ]}
            ]}]
          },
          "TimeUnit": "MONTHLY",
          "BudgetType": "COST"
        },
        "NotificationsWithSubscribers": [
          {
            "Notification": {
              "ComparisonOperator": "GREATER_THAN",
              "NotificationType": "ACTUAL",
              "Threshold": {
                "Ref": "WarningThreshold"
              },
              "ThresholdType": "ABSOLUTE_VALUE"
            },
            "Subscribers": [
              {
                "SubscriptionType": "EMAIL",
                "Address": {
                  "Ref": "Email"
                }
              }
            ]
          },
          {
            "Notification": {
              "ComparisonOperator": "GREATER_THAN",
              "NotificationType": "ACTUAL",
              "Threshold": {
                "Ref": "Amount"
              },
              "ThresholdType": "ABSOLUTE_VALUE"
            },
            "Subscribers": [
              {
                "SubscriptionType": "EMAIL",
                "Address": {
                  "Ref": "Email"
                }
              }
            ]
          }
        ]
      }
    },
    "BudgetAction": {
      "Type": "AWS::Budgets::BudgetsAction",
      "Properties": {
        "ActionThreshold": {
          "Type": "ABSOLUTE_VALUE",
          "Value": {
            "Ref": "Amount"
          }
        },
        "ActionType": "APPLY_IAM_POLICY",
        "ApprovalModel": "AUTOMATIC",
        "BudgetName": {
          "Ref": "Name"
        },
        "Definition": {
          "IamActionDefinition": {
            "PolicyArn": {
              "Fn::GetAtt": [
                { "Fn::Sub": "${Service}IamActionDefinitionPolicy" },
                "PolicyArn"
              ]
            },
            "Roles": 
              {
                "Ref": "IAMRoles"
              }
            
          }
        },
        "ExecutionRoleArn": {
          "Fn::GetAtt": ["BudgetActionExecutionRole", "Arn"]
        },
        "NotificationType": "ACTUAL",

        "Subscribers": [
          {
            "Type": "EMAIL",
            "Address": {
              "Ref": "Email"
            }
          }
        ]
      }
    }
  }
}

```


Discussion:

Resolving patch:
diff --git a/src/cfnlint/graph.py b/src/cfnlint/graph.py
--- a/src/cfnlint/graph.py
+++ b/src/cfnlint/graph.py
@@ -261,6 +261,8 @@ def _add_edge(self, source_id, target_id, source_path, settings):
 
     def _is_resource(self, cfn, identifier):
         """Check if the identifier is that of a Resource"""
+        if not isinstance(identifier, str):
+            return None
         return cfn.template.get("Resources", {}).get(identifier, {})
 
     def _find_parameter(self, string):


Test patch: