Repository: aws-cloudformation/cfn-lint

Issue:
False Positive for E3032
### CloudFormation Lint Version

cfn-lint 1.9.1

### What operating system are you using?

windows

### Describe the bug

error: [cfn-lint] E3032: [] is too short (1)

each member of the list its erroring on is conditionally created. there is a condition on the resource that is an OR of each conditional created member of the list. In the sample template below, the resource only gets created if atleast 1 of the deployment target OUs paramter for deployment is set to true. There was no error in v0.x


### Expected behavior

no error created. i have worked around the error by utilizing the resource metadata to ignore the rule

### Reproduction template

```
AWSTemplateFormatVersion: 2010-09-09

Parameters:
  Param1:
    Type: String
    Default: false
    AllowedValues:
      - false
      - true

  Param2:
    Type: String
    Default: false
    AllowedValues:
      - false
      - true

  Param3:
    Type: String
    Default: false
    AllowedValues:
      - false
      - true

Conditions:
  # Deployment Conditions
  ConditionValue1: !Equals [!Ref Param1, true]
  ConditionValue2: !Equals [!Ref Param2, true]
  ConditionValue3: !Equals [!Ref Param3, true]

  ConditionCreateStackSet: !Or
    - !Condition ConditionValue1
    - !Condition ConditionValue2
    - !Condition ConditionValue3

Resources:
  StackSet:
    #Metadata:
    #  cfn-lint:
    #    config:
    #      ignore_checks:
    #        - E3032 # Resource Doesnt Create if all Deployment OUs are false
    Type: AWS::CloudFormation::StackSet
    Condition: ConditionCreateStackSet
    Properties:
      AutoDeployment:
        Enabled: true
        RetainStacksOnAccountRemoval: true
      CallAs: SELF
      Description: 'Test123'
      ManagedExecution:
        Active: true
      OperationPreferences:
        FailureTolerancePercentage: 100
        MaxConcurrentPercentage: 100
        RegionConcurrencyType: PARALLEL
      PermissionModel: SERVICE_MANAGED
      StackInstancesGroup:
        - DeploymentTargets:
            OrganizationalUnitIds:
              - !If
                - ConditionValue1
                - ou-1234a-12345678
                - !Ref AWS::NoValue
              - !If
                - ConditionValue2
                - ou-1234b-12345678
                - !Ref AWS::NoValue
              - !If
                - ConditionValue3
                - ou-1234c-12345678
                - !Ref AWS::NoValue
          Regions:
            - !Ref AWS::Region
      StackSetName: 'test'
      TemplateBody: |
        AWSTemplateFormatVersion: '2010-09-09'

```


Discussion:
Thanks for submitting this.  Working on a fix now.
Reworked a decent chunk of how we filter templates during json schema validation.  Current we were using the v0 approach to determine when certain conditions apply. The PR I'm working on now will use the v1 method which will add for accountability to resource level conditions when processing the path.  We are going down a path that it may be hard to evaluate paths that can't be reached (Example: going to down the truth and false path of the same condition) but we will at least provide a warning for when a code path can't be reached. 



Patch:
diff --git a/src/cfnlint/context/conditions/_conditions.py b/src/cfnlint/context/conditions/_conditions.py
index 9403c4203a..3f50720f8f 100644
--- a/src/cfnlint/context/conditions/_conditions.py
+++ b/src/cfnlint/context/conditions/_conditions.py
@@ -5,8 +5,9 @@
 
 from __future__ import annotations
 
+import itertools
 from dataclasses import dataclass, field
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, Iterator
 
 from sympy import Not, Or
 from sympy.logic.boolalg import BooleanFunction
@@ -14,6 +15,10 @@
 
 from cfnlint.conditions._utils import get_hash
 from cfnlint.context.conditions._condition import Condition
+from cfnlint.context.conditions._utils import (
+    build_instance_from_scenario,
+    get_conditions_from_property,
+)
 from cfnlint.context.conditions.exceptions import Unsatisfiable
 
 if TYPE_CHECKING:
@@ -25,6 +30,7 @@ class Conditions:
     # Template level condition management
     conditions: dict[str, Condition] = field(init=True, default_factory=dict)
     cnf: BooleanFunction | None = field(init=True, default=None)
+    _max_scenarios: int = field(init=False, default=128)
 
     @classmethod
     def create_from_instance(
@@ -106,6 +112,30 @@ def evolve(self, status: dict[str, bool]) -> "Conditions":
             cnf=cnf,
         )
 
+    def _build_conditions(self, conditions: set[str]) -> Iterator["Conditions"]:
+        scenarios_attempted = 0
+        for product in itertools.product([True, False], repeat=len(conditions)):
+            params = dict(zip(conditions, product))
+            try:
+                yield self.evolve(params)
+            except Unsatisfiable:
+                pass
+
+            scenarios_attempted += 1
+            # On occassions people will use a lot of non-related conditions
+            # this is fail safe to limit the maximum number of responses
+            if scenarios_attempted >= self._max_scenarios:
+                return
+
+    def evolve_from_instance(self, instance: Any) -> Iterator[tuple[Any, "Conditions"]]:
+
+        conditions = get_conditions_from_property(instance)
+
+        for scenario in self._build_conditions(conditions):
+            yield build_instance_from_scenario(
+                instance, scenario.status, is_root=True
+            ), scenario
+
     @property
     def status(self) -> dict[str, bool]:
         obj = {}
diff --git a/src/cfnlint/context/conditions/_utils.py b/src/cfnlint/context/conditions/_utils.py
new file mode 100644
index 0000000000..74b33bf56c
--- /dev/null
+++ b/src/cfnlint/context/conditions/_utils.py
@@ -0,0 +1,112 @@
+"""
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+SPDX-License-Identifier: MIT-0
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from cfnlint.decode.node import Mark, dict_node, list_node
+from cfnlint.helpers import is_function
+
+
+def get_conditions_from_property(instance: Any, is_root: bool = True) -> set[str]:
+    """
+    Gets the name of the conditions used directly inside the object.
+
+    We do not look at nested objects for conditions.
+
+    Args:
+        instance (Any): The object or listto process.
+        is_root (bool): If we are at the root of the object. Default: True.
+
+    Returns:
+        set[str]: The set of conditions used in the object or list.
+    """
+    results: set[str] = set()
+    if isinstance(instance, list):
+        for v in instance:
+            results = results.union(get_conditions_from_property(v, is_root=False))
+        return results
+
+    fn_k, fn_v = is_function(instance)
+    if fn_k == "Fn::If":
+        if isinstance(fn_v, list) and len(fn_v) == 3:
+            if isinstance(fn_v[0], str):
+                results.add(fn_v[0])
+                results = results.union(
+                    get_conditions_from_property(fn_v[1], is_root=is_root)
+                )
+                results = results.union(
+                    get_conditions_from_property(fn_v[2], is_root=is_root)
+                )
+        return results
+    if is_root:
+        if isinstance(instance, dict):
+            for k, v in instance.items():
+                results = results.union(get_conditions_from_property(v, is_root=False))
+    return results
+
+
+def build_instance_from_scenario(
+    instance: Any, scenario: dict[str, bool], is_root: bool = True
+) -> Any:
+    """
+    Get object values from a provided scenario.
+
+    This function recursively processes the provided object, resolving any
+    conditional logic (such as Fn::If) based on the given scenario.
+
+    Args:
+        instance (Any): The object or listto process.
+        scenario (dict): The scenario to use when resolving conditional logic.
+        is_root (bool): If we are at the root of the object. Default: True.
+
+    Returns:
+        dict or list or any: The processed object, with conditional logic resolved.
+        The return type can be a dictionary, list, or any other data type,
+        depending on the structure of the input object.
+    """
+
+    if isinstance(instance, list):
+        new_list: list[Any] = list_node(
+            [],
+            getattr(instance, "start_mark", Mark(0, 0)),
+            getattr(instance, "end_mark", Mark(0, 0)),
+        )
+        for v in instance:
+            new_value = build_instance_from_scenario(v, scenario, is_root=False)
+            if new_value is not None:
+                new_list.append(new_value)
+        return new_list
+
+    if isinstance(instance, dict):
+        fn_k, fn_v = is_function(instance)
+        if fn_k == "Fn::If":
+            if isinstance(fn_v, list) and len(fn_v) == 3:
+                if isinstance(fn_v[0], str):
+                    if_path = scenario.get(fn_v[0], None)
+                    if if_path is not None:
+                        new_value = build_instance_from_scenario(
+                            fn_v[1] if if_path else fn_v[2], scenario, is_root
+                        )
+                        if new_value is not None:
+                            return new_value
+                        return None
+            return instance
+        if fn_k == "Ref" and fn_v == "AWS::NoValue":
+            return {} if is_root else None
+        if is_root:
+            new_obj: dict[str, Any] = dict_node(
+                {},
+                getattr(instance, "start_mark", Mark(0, 0)),
+                getattr(instance, "end_mark", Mark(0, 0)),
+            )
+            for k, v in instance.items():
+                new_value = build_instance_from_scenario(v, scenario, is_root=False)
+                if new_value is not None:
+                    new_obj[k] = new_value
+            return new_obj
+
+    return instance
diff --git a/src/cfnlint/jsonschema/_filter.py b/src/cfnlint/jsonschema/_filter.py
index 3cd695c99a..82ca42844c 100644
--- a/src/cfnlint/jsonschema/_filter.py
+++ b/src/cfnlint/jsonschema/_filter.py
@@ -6,7 +6,7 @@
 from __future__ import annotations
 
 from dataclasses import dataclass, field, fields
-from typing import TYPE_CHECKING, Any, Sequence, Tuple
+from typing import TYPE_CHECKING, Any, Iterator, Sequence, Tuple
 
 from cfnlint.helpers import FUNCTIONS, REGEX_DYN_REF, ToPy, ensure_list
 
@@ -107,7 +107,9 @@ def _filter_schemas(self, schema, validator: Validator) -> Tuple[Any, Any]:
 
         return standard_schema, group_schema
 
-    def filter(self, validator: Any, instance: Any, schema: Any):
+    def filter(
+        self, validator: Any, instance: Any, schema: Any
+    ) -> Iterator[tuple[Any, dict[str, Any], "Validator"]]:
         # Lets validate dynamic references when appropriate
         if validator.is_type(instance, "string") and self.validate_dynamic_references:
             if REGEX_DYN_REF.findall(instance):
@@ -117,14 +119,14 @@ def filter(self, validator: Any, instance: Any, schema: Any):
                     p in set(FUNCTIONS) - set(["Fn::If"])
                     for p in validator.context.path.path
                 ):
-                    yield (instance, {"dynamicReference": schema})
+                    yield (instance, {"dynamicReference": schema}, validator)
                     return
                 return
 
         # if there are no functions then we don't need to worry
         # about ref AWS::NoValue or If conditions
         if not validator.context.functions:
-            yield instance, schema
+            yield instance, schema, validator
             return
 
         # dependencies, required, minProperties, maxProperties
@@ -133,9 +135,14 @@ def filter(self, validator: Any, instance: Any, schema: Any):
         standard_schema, group_schema = self._filter_schemas(schema, validator)
 
         if group_schema:
-            scenarios = validator.cfn.get_object_without_conditions(instance)
-            for scenario in scenarios:
-                yield (scenario.get("Object"), group_schema)
+            for (
+                scenario_instance,
+                scenario,
+            ) in validator.context.conditions.evolve_from_instance(instance):
+                scenario_validator = validator.evolve(
+                    context=validator.context.evolve(conditions=scenario)
+                )
+                yield (scenario_instance, group_schema, scenario_validator)
 
         if validator.is_type(instance, "object"):
             if len(instance) == 1:
@@ -145,10 +152,10 @@ def filter(self, validator: Any, instance: Any, schema: Any):
                         k_schema = {
                             k_py.py: standard_schema,
                         }
-                        yield (instance, k_schema)
+                        yield (instance, k_schema, validator)
                         return
 
-        yield (instance, standard_schema)
+        yield (instance, standard_schema, validator)
 
     def evolve(self, **kwargs) -> "FunctionFilter":
         """
diff --git a/src/cfnlint/jsonschema/validators.py b/src/cfnlint/jsonschema/validators.py
index e6c5ada5a3..98263ca8a5 100644
--- a/src/cfnlint/jsonschema/validators.py
+++ b/src/cfnlint/jsonschema/validators.py
@@ -229,7 +229,7 @@ def iter_errors(self, instance: Any) -> ValidationResult:
                 self.resolver.push_scope(scope)
             try:
                 # we need filter and apply schemas against the new instances
-                for _instance, _schema in self.function_filter.filter(
+                for _instance, _schema, _validator in self.function_filter.filter(
                     self, instance, schema
                 ):
                     for k, v in _schema.items():
@@ -238,7 +238,9 @@ def iter_errors(self, instance: Any) -> ValidationResult:
                             continue
 
                         try:
-                            for err in validator(self, v, _instance, _schema) or ():
+                            for err in (
+                                validator(_validator, v, _instance, _schema) or ()
+                            ):
                                 msg = custom_msg(k, _schema) or err.message
                                 if msg is not None:
                                     err.message = msg
diff --git a/src/cfnlint/template/template.py b/src/cfnlint/template/template.py
index d14b562910..6e78c4cd4f 100644
--- a/src/cfnlint/template/template.py
+++ b/src/cfnlint/template/template.py
@@ -962,7 +962,7 @@ def get_value(value, scenario):  # pylint: disable=R0911
                         result[key] = new_value
             return result
         if isinstance(obj, list):
-            result = list_node({}, obj.start_mark, obj.end_mark)
+            result = list_node([], obj.start_mark, obj.end_mark)
             for item in obj:
                 element = get_value(item, scenario)
                 if element is not None:


Test patch:
diff --git a/test/unit/module/context/conditions/test_conditions.py b/test/unit/module/context/conditions/test_conditions.py
index d5daccb2f0..57618b27c6 100644
--- a/test/unit/module/context/conditions/test_conditions.py
+++ b/test/unit/module/context/conditions/test_conditions.py
@@ -185,6 +185,112 @@ def test_condition_status(current_status, new_status, expected):
         assert context.conditions.status == expected
 
 
+@pytest.mark.parametrize(
+    "current_status,instance,expected",
+    [
+        ({}, {"Foo": "Bar"}, [({"Foo": "Bar"}, {})]),
+        (
+            {},
+            {"Fn::If": ["IsUsEast1", {"Foo": "Foo"}, {"Bar": "Bar"}]},
+            [
+                ({"Foo": "Foo"}, {"IsUsEast1": True}),
+                ({"Bar": "Bar"}, {"IsUsEast1": False}),
+            ],
+        ),
+        (
+            {
+                "IsUsEast1": True,
+            },
+            {"Fn::If": ["IsUsEast1", {"Foo": "Foo"}, {"Bar": "Bar"}]},
+            [
+                ({"Foo": "Foo"}, {"IsUsEast1": True}),
+            ],
+        ),
+        (
+            {
+                "IsUsEast1": False,
+            },
+            {"Fn::If": ["IsUsEast1", {"Foo": "Foo"}, {"Bar": "Bar"}]},
+            [
+                ({"Bar": "Bar"}, {"IsUsEast1": False}),
+            ],
+        ),
+        (
+            {},
+            {"Ref": "AWS::NoValue"},
+            [
+                ({}, {}),
+            ],
+        ),
+        (
+            {},
+            [{"Foo": {"Fn::If": ["IsUsEast1", "Foo", "Bar"]}}],
+            [
+                ([{"Foo": {"Fn::If": ["IsUsEast1", "Foo", "Bar"]}}], {}),
+            ],
+        ),
+        (
+            {},
+            [{"Fn::If": ["IsUsEast1", {"Foo": "Bar"}, {"Ref": "AWS::NoValue"}]}],
+            [
+                ([{"Foo": "Bar"}], {"IsUsEast1": True}),
+                ([], {"IsUsEast1": False}),
+            ],
+        ),
+        (
+            {"IsUsEast1": True, "IsProd": True},
+            {
+                "A": {"Fn::If": ["IsUsEast1AndProd", 1, 2]},
+                "B": {"Fn::If": ["IsAi", 10, 11]},
+            },
+            [
+                (
+                    {"A": 1, "B": 10},
+                    {
+                        "IsUsEast1": True,
+                        "IsProd": True,
+                        "IsUsEast1AndProd": True,
+                        "IsAi": True,
+                    },
+                ),
+                (
+                    {"A": 1, "B": 11},
+                    {
+                        "IsUsEast1": True,
+                        "IsProd": True,
+                        "IsUsEast1AndProd": True,
+                        "IsAi": False,
+                    },
+                ),
+            ],
+        ),
+        (
+            {},
+            {
+                "A": {"Fn::If": ["IsUsEast1AndProd", 1]},
+            },
+            [
+                (
+                    {"A": {"Fn::If": ["IsUsEast1AndProd", 1]}},
+                    {},
+                ),
+            ],
+        ),
+    ],
+)
+def test_evolve_from_instance(current_status, instance, expected):
+    cfn = Template(None, template(), regions=["us-east-1"])
+    context = create_context_for_template(cfn)
+
+    context = context.evolve(conditions=context.conditions.evolve(current_status))
+
+    results = list(context.conditions.evolve_from_instance(instance))
+    assert len(results) == len(expected)
+    for result, expected_result in zip(results, expected):
+        assert result[0] == expected_result[0]
+        assert result[1].status == expected_result[1]
+
+
 def test_condition_failures():
     with pytest.raises(ValueError):
         Conditions.create_from_instance([], {})
diff --git a/test/unit/module/jsonschema/test_filter.py b/test/unit/module/jsonschema/test_filter.py
index 9d399e845b..81a7f91e28 100644
--- a/test/unit/module/jsonschema/test_filter.py
+++ b/test/unit/module/jsonschema/test_filter.py
@@ -86,4 +86,8 @@ def test_filter(name, instance, schema, path, functions, expected, filter):
     )
     results = list(filter.filter(validator, instance, schema))
 
-    assert results == expected, f"For test {name} got {results!r}"
+    assert len(results) == len(expected), f"For test {name} got {len(results)} results"
+
+    for result, (exp_instance, exp_schema) in zip(results, expected):
+        assert result[0] == exp_instance, f"For test {name} got {result.instance!r}"
+        assert result[1] == exp_schema, f"For test {name} got {result.schema!r}"
diff --git a/test/unit/rules/resources/ec2/test_instance_image_id.py b/test/unit/rules/resources/ec2/test_instance_image_id.py
index 2db06f443e..53a0883ea1 100644
--- a/test/unit/rules/resources/ec2/test_instance_image_id.py
+++ b/test/unit/rules/resources/ec2/test_instance_image_id.py
@@ -280,10 +280,6 @@ def rule():
 def test_validate(name, instance, expected, rule, validator):
     errs = list(rule.validate(validator, "", instance, {}))
 
-    for err in errs:
-        print(err.path)
-        print(err.validator)
-        print(err.rule)
     assert (
         errs == expected
     ), f"Expected test {name!r} to have {expected!r} but got {errs!r}"