Repository: aws-cloudformation/cfn-lint

Issue:
W3037 erroring on wildcard in action
### CloudFormation Lint Version

1.30.0

### What operating system are you using?

Ubuntu 24.04

### Describe the bug

The following policystatements are no longer accepted since version 1.30.0 (W3027 no longer experimental)


We get the following error:

```
W3037 'list*tags' is not one of ['addclientidtoopenidconnectprovider', ...]
```

### Expected behavior

wildcards within actions should be allowed (https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_action.html)

### Reproduction template

```
{
  "Action": [
    "iam:UpdateAssumeRolePolicy",
    "iam:List*Tags",
    "iam:Tag*",
    "iam:Untag*"
  ],
  "Effect": "Allow",
  "NotResource": {
    "Fn::Sub": "arn:aws:iam::${AWS::AccountId}:role/admin"
  },
  "Sid": "CloudWatchAndOtherIAMTasks"
}
```

Hints from issue comments:

Solution patch:
diff --git a/src/cfnlint/rules/resources/iam/Permissions.py b/src/cfnlint/rules/resources/iam/Permissions.py
index 35576af1a..bd150cf07 100644
--- a/src/cfnlint/rules/resources/iam/Permissions.py
+++ b/src/cfnlint/rules/resources/iam/Permissions.py
@@ -7,6 +7,8 @@ from __future__ import annotations
 
 from typing import Any
 
+import regex as re
+
 from cfnlint.data import AdditionalSpecs
 from cfnlint.helpers import ensure_list, load_resource
 from cfnlint.jsonschema import ValidationError, ValidationResult, Validator
@@ -61,17 +63,12 @@ class Permissions(CfnLintKeyword):
                 enums = list(self.service_map[service].get("Actions", []).keys())
                 if permission == "*":
                     pass
-                elif permission.endswith("*"):
-                    wilcarded_permission = permission.split("*")[0]
-                    if not any(wilcarded_permission in action for action in enums):
-                        yield ValidationError(
-                            f"{permission!r} is not one of {enums!r}",
-                            rule=self,
-                        )
 
-                elif permission.startswith("*"):
-                    wilcarded_permission = permission.split("*")[1]
-                    if not any(wilcarded_permission in action for action in enums):
+                if any(x in permission for x in ["*", "?"]):
+                    permission_regex = (
+                        f"^{permission.replace('*', '.*').replace('?', '.')}$"
+                    )
+                    if not any(re.match(permission_regex, action) for action in enums):
                         yield ValidationError(
                             f"{permission!r} is not one of {enums!r}",
                             rule=self,
diff --git a/src/cfnlint/rules/resources/iam/StatementResources.py b/src/cfnlint/rules/resources/iam/StatementResources.py
index ddbb5a6b0..ffbe4d067 100644
--- a/src/cfnlint/rules/resources/iam/StatementResources.py
+++ b/src/cfnlint/rules/resources/iam/StatementResources.py
@@ -101,24 +101,25 @@ class StatementResources(CfnLintKeyword):
 
         using_fn_arns = False
         all_resources: set[str] = set()
-        for resources, _ in get_value_from_path(
-            validator, instance, path=deque(["Resource"])
-        ):
-            resources = ensure_list(resources)
-
-            for resource in resources:
-                if not isinstance(resource, str):
-                    k, v = is_function(resource)
-                    if k is None:
+        for key in ["Resource", "NotResource"]:
+            for resources, _ in get_value_from_path(
+                validator, instance, path=deque([key])
+            ):
+                resources = ensure_list(resources)
+
+                for resource in resources:
+                    if not isinstance(resource, str):
+                        k, v = is_function(resource)
+                        if k is None:
+                            continue
+                        if k == "Ref" and validator.is_type(v, "string"):
+                            if v in validator.context.parameters:
+                                return
+                        using_fn_arns = True
                         continue
-                    if k == "Ref" and validator.is_type(v, "string"):
-                        if v in validator.context.parameters:
-                            return
-                    using_fn_arns = True
-                    continue
-                if resource == "*":
-                    return
-                all_resources.add(resource)
+                    if resource == "*":
+                        return
+                    all_resources.add(resource)
 
         all_resource_arns = [_Arn(a) for a in all_resources]
         for actions, _ in get_value_from_path(
@@ -130,7 +131,7 @@ class StatementResources(CfnLintKeyword):
 
                 if not validator.is_type(action, "string"):
                     continue
-                if "*" in action:
+                if any(x in action for x in ["*", "?"]):
                     continue
                 if ":" not in action:
                     continue


Test patch:
diff --git a/test/unit/rules/resources/iam/test_iam_permissions.py b/test/unit/rules/resources/iam/test_iam_permissions.py
index a78a2f191..ed23c940b 100644
--- a/test/unit/rules/resources/iam/test_iam_permissions.py
+++ b/test/unit/rules/resources/iam/test_iam_permissions.py
@@ -31,8 +31,14 @@ def rule():
         ("Invalid service", "foo:Bar", 1),
         ("Empty string", "", 1),
         ("A function", {"Ref": "MyParameter"}, 0),
+        ("asterisk in the middle", "iam:*Tags", 0),
+        ("multiple asterisks good", "iam:*Group*", 0),
+        ("multiple asterisks bad", "iam:*ec2*", 1),
+        ("question mark is bad", "iam:Tag?", 1),
+        ("question mark is good", "iam:TagRol?", 0),
     ],
 )
 def test_permissions(name, instance, err_count, rule, validator):
     errors = list(rule.validate(validator, {}, instance, {}))
+
     assert len(errors) == err_count, f"Test {name!r} got {errors!r}"
diff --git a/test/unit/rules/resources/iam/test_statement_resources.py b/test/unit/rules/resources/iam/test_statement_resources.py
index 28c28d0f8..c5e10ba41 100644
--- a/test/unit/rules/resources/iam/test_statement_resources.py
+++ b/test/unit/rules/resources/iam/test_statement_resources.py
@@ -243,6 +243,13 @@ def template():
                 ),
             ],
         ),
+        (
+            {
+                "Action": ["cloudformation:Create?tack"],
+                "Resource": ["arn:aws:logs:us-east-1:123456789012:stack/dne/*"],
+            },
+            [],
+        ),
         (
             {
                 "Action": ["cloudformation:CreateStack"],