diff --git a/src/cfnlint/data/schemas/other/rules/__init__.py b/src/cfnlint/data/schemas/other/rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/cfnlint/data/schemas/other/rules/configuration.json b/src/cfnlint/data/schemas/other/rules/configuration.json new file mode 100644 index 0000000000..9f26a54d3e --- /dev/null +++ b/src/cfnlint/data/schemas/other/rules/configuration.json @@ -0,0 +1,35 @@ +{ + "additionalProperties": false, + "definitions": { + "assertion": { + "properties": { + "Assert": {}, + "AssertDescription": { + "type": "string" + } + }, + "type": "object" + }, + "rule": { + "properties": { + "Assertions": { + "items": { + "$ref": "#/definitions/assertion" + }, + "type": "array" + }, + "RuleCondition": {} + }, + "required": [ + "Assertions" + ], + "type": "object" + } + }, + "patternProperties": { + "^[A-Za-z0-9]+$": { + "$ref": "#/definitions/rule" + } + }, + "type": "object" +} diff --git a/src/cfnlint/helpers.py b/src/cfnlint/helpers.py index 4ef472929a..77c2283fb5 100644 --- a/src/cfnlint/helpers.py +++ b/src/cfnlint/helpers.py @@ -185,12 +185,33 @@ FUNCTION_NOT = "Fn::Not" FUNCTION_EQUALS = "Fn::Equals" FUNCTION_BASE64 = "Fn::Base64" +FUNCTION_CONTAINS = "Fn::Contains" +FUNCTION_EACH_MEMBER_EQUALS = "Fn::EachMemberEquals" +FUNCTION_EACH_MEMBER_IN = "Fn::EachMemberIn" +FUNCTION_REF_ALL = "Fn::RefAll" +FUNCTION_VALUE_OF = "Fn::ValueOf" +FUNCTION_VALUE_OF_ALL = "Fn::ValueOfAll" FUNCTION_FOR_EACH = re.compile(r"^Fn::ForEach::[a-zA-Z0-9]+$") FUNCTION_CONDITIONS = frozenset( [FUNCTION_AND, FUNCTION_OR, FUNCTION_NOT, FUNCTION_EQUALS] ) +FUNCTION_RULES = frozenset( + [ + FUNCTION_AND, + FUNCTION_OR, + FUNCTION_NOT, + FUNCTION_EQUALS, + FUNCTION_CONTAINS, + FUNCTION_EACH_MEMBER_EQUALS, + FUNCTION_EACH_MEMBER_IN, + FUNCTION_REF_ALL, + FUNCTION_VALUE_OF, + FUNCTION_VALUE_OF_ALL, + ] +) + FUNCTIONS_ALL = frozenset.union( *[FUNCTIONS, FUNCTION_CONDITIONS, frozenset(["Condition"])] ) diff --git a/src/cfnlint/rules/resources/ectwo/RouteTableAssociation.py b/src/cfnlint/rules/resources/ectwo/RouteTableAssociation.py index 9aa6f0dda8..d6839da87f 100644 --- a/src/cfnlint/rules/resources/ectwo/RouteTableAssociation.py +++ b/src/cfnlint/rules/resources/ectwo/RouteTableAssociation.py @@ -36,7 +36,7 @@ def get_values(self, subnetid, resource_condition, property_condition): if isinstance(subnetid, dict): if len(subnetid) == 1: for key, value in subnetid.items(): - if key in cfnlint.helpers.CONDITION_FUNCTIONS: + if key == cfnlint.helpers.FUNCTION_IF: if isinstance(value, list): if len(value) == 3: property_condition = value[0] diff --git a/src/cfnlint/rules/rules/Assert.py b/src/cfnlint/rules/rules/Assert.py new file mode 100644 index 0000000000..09d9579f3a --- /dev/null +++ b/src/cfnlint/rules/rules/Assert.py @@ -0,0 +1,41 @@ +""" +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.helpers import FUNCTION_RULES +from cfnlint.jsonschema import ValidationResult, Validator +from cfnlint.rules.jsonschema.CfnLintJsonSchema import CfnLintJsonSchema + + +class Assert(CfnLintJsonSchema): + id = "E1701" + shortdesc = "Validate the configuration of Assertions" + description = "Make sure the Assert value in a Rule is properly configured" + source_url = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/rules-section-structure.html" + tags = ["rules"] + + def __init__(self): + super().__init__( + keywords=["Rules/*/Assertions/*/Assert"], + all_matches=True, + ) + + def validate( + self, validator: Validator, keywords: Any, instance: Any, schema: dict[str, Any] + ) -> ValidationResult: + validator = validator.evolve( + context=validator.context.evolve( + functions=list(FUNCTION_RULES) + ["Condition"], + ), + function_filter=validator.function_filter.evolve( + add_cfn_lint_keyword=False, + ), + schema={"type": "boolean"}, + ) + + yield from self._iter_errors(validator, instance) diff --git a/src/cfnlint/rules/rules/Configuration.py b/src/cfnlint/rules/rules/Configuration.py new file mode 100644 index 0000000000..b0eef93433 --- /dev/null +++ b/src/cfnlint/rules/rules/Configuration.py @@ -0,0 +1,67 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from __future__ import annotations + +from typing import Any + +import cfnlint.data.schemas.other.rules +from cfnlint.jsonschema import Validator +from cfnlint.jsonschema._keywords import patternProperties +from cfnlint.jsonschema._keywords_cfn import cfn_type +from cfnlint.rules.jsonschema.CfnLintJsonSchema import CfnLintJsonSchema, SchemaDetails + + +class Configuration(CfnLintJsonSchema): + id = "E1700" + shortdesc = "Rules have the appropriate configuration" + description = "Making sure the Rules section is properly configured" + source_url = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/rules-section-structure.html" + tags = ["rules"] + + def __init__(self): + super().__init__( + keywords=["Rules"], + schema_details=SchemaDetails( + cfnlint.data.schemas.other.rules, "configuration.json" + ), + all_matches=True, + ) + self.validators = { + "type": cfn_type, + "patternProperties": self._pattern_properties, + } + + def _pattern_properties( + self, validator: Validator, aP: Any, instance: Any, schema: Any + ): + # We have to rework pattern properties + # to re-add the keyword or we will have an + # infinite loop + validator = validator.evolve( + function_filter=validator.function_filter.evolve( + add_cfn_lint_keyword=True, + ) + ) + yield from patternProperties(validator, aP, instance, schema) + + def validate( + self, validator: Validator, conditions: Any, instance: Any, schema: Any + ): + rule_validator = self.extend_validator( + validator=validator, + schema=self.schema, + context=validator.context.evolve( + resources={}, + strict_types=False, + ), + ).evolve( + context=validator.context.evolve(strict_types=False), + function_filter=validator.function_filter.evolve( + add_cfn_lint_keyword=False, + ), + ) + + yield from super()._iter_errors(rule_validator, instance) diff --git a/src/cfnlint/rules/rules/RuleCondition.py b/src/cfnlint/rules/rules/RuleCondition.py new file mode 100644 index 0000000000..0689fab9ca --- /dev/null +++ b/src/cfnlint/rules/rules/RuleCondition.py @@ -0,0 +1,41 @@ +""" +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.helpers import FUNCTION_RULES +from cfnlint.jsonschema import ValidationResult, Validator +from cfnlint.rules.jsonschema.CfnLintJsonSchema import CfnLintJsonSchema + + +class RuleCondition(CfnLintJsonSchema): + id = "E1702" + shortdesc = "Validate the configuration of Rules RuleCondition" + description = "Make sure the RuleCondition in a Rule is properly configured" + source_url = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/rules-section-structure.html" + tags = ["rules"] + + def __init__(self): + super().__init__( + keywords=["Rules/*/RuleCondition"], + all_matches=True, + ) + + def validate( + self, validator: Validator, keywords: Any, instance: Any, schema: dict[str, Any] + ) -> ValidationResult: + validator = validator.evolve( + context=validator.context.evolve( + functions=list(FUNCTION_RULES) + ["Condition"], + ), + function_filter=validator.function_filter.evolve( + add_cfn_lint_keyword=False, + ), + schema={"type": "boolean"}, + ) + + yield from self._iter_errors(validator, instance) diff --git a/src/cfnlint/rules/rules/__init__.py b/src/cfnlint/rules/rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/cfnlint/template/template.py b/src/cfnlint/template/template.py index 6e78c4cd4f..e7a02fe056 100644 --- a/src/cfnlint/template/template.py +++ b/src/cfnlint/template/template.py @@ -462,7 +462,7 @@ def get_condition_values(self, template, path: Path | None) -> list[dict[str, An # Checking for conditions inside of conditions if isinstance(item, dict): for sub_key, sub_value in item.items(): - if sub_key in cfnlint.helpers.CONDITION_FUNCTIONS: + if sub_key == cfnlint.helpers.FUNCTION_IF: results = self.get_condition_values( sub_value, result["Path"] + [sub_key] ) @@ -521,7 +521,7 @@ def get_values(self, obj, key, path: Path | None = None): is_condition = False is_no_value = False for obj_key, obj_value in value.items(): - if obj_key in cfnlint.helpers.CONDITION_FUNCTIONS: + if obj_key == cfnlint.helpers.FUNCTION_IF: is_condition = True results = self.get_condition_values( obj_value, path[:] + [obj_key] @@ -552,7 +552,7 @@ def get_values(self, obj, key, path: Path | None = None): is_condition = False is_no_value = False for obj_key, obj_value in list_value.items(): - if obj_key in cfnlint.helpers.CONDITION_FUNCTIONS: + if obj_key == cfnlint.helpers.FUNCTION_IF: is_condition = True results = self.get_condition_values( obj_value, path[:] + [list_index, obj_key] diff --git a/test/integration/test_schema_files.py b/test/integration/test_schema_files.py index e3c097daa0..57e14d3ddf 100644 --- a/test/integration/test_schema_files.py +++ b/test/integration/test_schema_files.py @@ -52,6 +52,9 @@ class TestSchemaFiles(TestCase): "Resources/*/Type", "Resources/*/UpdatePolicy", "Resources/*/UpdateReplacePolicy", + "Rules", + "Rules/*/Assertions/*/Assert", + "Rules/*/RuleCondition", "Transform", ] diff --git a/test/unit/rules/rules/__init__.py b/test/unit/rules/rules/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/unit/rules/rules/test_assert.py b/test/unit/rules/rules/test_assert.py new file mode 100644 index 0000000000..875b5c32d5 --- /dev/null +++ b/test/unit/rules/rules/test_assert.py @@ -0,0 +1,50 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from collections import deque + +import pytest + +from cfnlint.jsonschema import ValidationError +from cfnlint.rules.rules.Assert import Assert + + +@pytest.fixture(scope="module") +def rule(): + rule = Assert() + yield rule + + +@pytest.mark.parametrize( + "name,instance,expected", + [ + ( + "boolean is okay", + True, + [], + ), + ( + "wrong type", + [], + [ + ValidationError( + "[] is not of type 'boolean'", + validator="type", + schema_path=deque(["type"]), + rule=Assert(), + ) + ], + ), + ( + "functions are okay", + {"Fn::Equals": ["a", "b"]}, + [], + ), + ], +) +def test_validate(name, instance, expected, rule, validator): + errs = list(rule.validate(validator, {}, instance, {})) + + assert errs == expected, f"Test {name!r} got {errs!r}" diff --git a/test/unit/rules/rules/test_configuration.py b/test/unit/rules/rules/test_configuration.py new file mode 100644 index 0000000000..793b09d481 --- /dev/null +++ b/test/unit/rules/rules/test_configuration.py @@ -0,0 +1,95 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from collections import deque + +import pytest + +from cfnlint.jsonschema import ValidationError +from cfnlint.rules.rules.Configuration import Configuration + + +@pytest.fixture(scope="module") +def rule(): + rule = Configuration() + yield rule + + +@pytest.mark.parametrize( + "name,instance,expected", + [ + ( + "Empty is okay", + {}, + [], + ), + ( + "wrong type", + [], + [ + ValidationError( + "[] is not of type 'object'", + validator="type", + schema_path=deque(["type"]), + rule=Configuration(), + ) + ], + ), + ( + "Wrong type of rule", + { + "Rule1": [], + }, + [ + ValidationError( + "[] is not of type 'object'", + validator="type", + schema_path=deque(["patternProperties", "^[A-Za-z0-9]+$", "type"]), + path=deque(["Rule1"]), + rule=Configuration(), + ) + ], + ), + ( + "Empty rule", + { + "Rule1": {}, + }, + [ + ValidationError( + "'Assertions' is a required property", + validator="required", + schema_path=deque( + ["patternProperties", "^[A-Za-z0-9]+$", "required"] + ), + path=deque(["Rule1"]), + rule=Configuration(), + ) + ], + ), + ( + "Valid rule with RuleCondition and Assertions", + { + "Rule1": { + "RuleCondition": {"Fn::Equals": ["a", "b"]}, + "Assertions": [ + { + "Assert": {"Fn::Equals": ["a", "b"]}, + "AssertDescription": "a is equal to b", + }, + { + "Assert": {"Fn::Equals": ["a", "b"]}, + }, + ], + }, + }, + [], + ), + ], +) +def test_validate(name, instance, expected, rule, validator): + errs = list(rule.validate(validator, {}, instance, {})) + + assert errs == expected, f"Test {name!r} got {errs!r}" diff --git a/test/unit/rules/rules/test_rule_condition.py b/test/unit/rules/rules/test_rule_condition.py new file mode 100644 index 0000000000..5729bfe769 --- /dev/null +++ b/test/unit/rules/rules/test_rule_condition.py @@ -0,0 +1,50 @@ +""" +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +SPDX-License-Identifier: MIT-0 +""" + +from collections import deque + +import pytest + +from cfnlint.jsonschema import ValidationError +from cfnlint.rules.rules.RuleCondition import RuleCondition + + +@pytest.fixture(scope="module") +def rule(): + rule = RuleCondition() + yield rule + + +@pytest.mark.parametrize( + "name,instance,expected", + [ + ( + "boolean is okay", + True, + [], + ), + ( + "wrong type", + [], + [ + ValidationError( + "[] is not of type 'boolean'", + validator="type", + schema_path=deque(["type"]), + rule=RuleCondition(), + ) + ], + ), + ( + "functions are okay", + {"Fn::Equals": ["a", "b"]}, + [], + ), + ], +) +def test_validate(name, instance, expected, rule, validator): + errs = list(rule.validate(validator, {}, instance, {})) + + assert errs == expected, f"Test {name!r} got {errs!r}"