Repository: bridgecrewio/checkov

Issue:
[checks][python] How to determine which fields are changing in a resource
While you can use `TF_PLAN_RESOURCE_CHANGE_ACTIONS` to see "did a resource change", I'm wanting to know "what field(s) changed"?

The plan data definitely exposes this, so it should be possible, at the very least. Is this already available and just not explicitly documented? If not, is that on the roadmap to add?


Discussion:
hey @tarfeef101, no this info is not available in `checkov` and currently there is no plan on adding it.

Resolving patch:
diff --git a/checkov/terraform/plan_parser.py b/checkov/terraform/plan_parser.py
--- a/checkov/terraform/plan_parser.py
+++ b/checkov/terraform/plan_parser.py
@@ -7,12 +7,14 @@
 
 from checkov.common.graph.graph_builder import CustomAttributes
 from checkov.common.parsers.node import ListNode
+from checkov.common.util.consts import LINE_FIELD_NAMES
 from checkov.common.util.type_forcers import force_list
 from checkov.terraform.context_parsers.tf_plan import parse
 
 SIMPLE_TYPES = (str, int, float, bool)
 TF_PLAN_RESOURCE_ADDRESS = "__address__"
 TF_PLAN_RESOURCE_CHANGE_ACTIONS = "__change_actions__"
+TF_PLAN_RESOURCE_CHANGE_KEYS = "__change_keys__"
 
 RESOURCE_TYPES_JSONIFY = {
     "aws_batch_job_definition": "container_properties",
@@ -129,7 +131,6 @@ def _prepare_resource_block(
     :param resource: tf planned_values resource block
     :param conf: tf configuration resource block
     :param resource_changes: tf resource_changes block
-
     :returns:
         - resource_block: a list of strings representing the header columns
         - prepared: whether conditions met to prepare data
@@ -161,6 +162,7 @@ def _prepare_resource_block(
         changes = resource_changes.get(resource_address)  # type:ignore[arg-type]  # becaus eit can be None
         if changes:
             resource_conf[TF_PLAN_RESOURCE_CHANGE_ACTIONS] = changes.get("change", {}).get("actions") or []
+            resource_conf[TF_PLAN_RESOURCE_CHANGE_KEYS] = changes.get(TF_PLAN_RESOURCE_CHANGE_KEYS) or []
 
         resource_block[resource_type][resource.get("name", "default")] = resource_conf
         prepared = True
@@ -186,7 +188,7 @@ def _find_child_modules(
             nested_blocks = _find_child_modules(
                 child_modules=nested_child_modules,
                 resource_changes=resource_changes,
-                root_module_conf=root_module_conf
+                root_module_conf=root_module_conf,
             )
             for block_type, resource_blocks in nested_blocks.items():
                 blocks[block_type].extend(resource_blocks)
@@ -238,13 +240,24 @@ def _get_resource_changes(template: dict[str, Any]) -> dict[str, dict[str, Any]]
     """Returns a resource address to resource changes dict"""
 
     resource_changes_map = {}
-
     resource_changes = template.get("resource_changes")
+
     if resource_changes and isinstance(resource_changes, list):
-        resource_changes_map = {
-            change.get("address", ""): change
-            for change in resource_changes
-        }
+        for resource in resource_changes:
+            resource_changes_map[resource["address"]] = resource
+            changes = []
+
+            # before + after are None when resources are created/destroyed, so make them safe
+            change_before = resource["change"]["before"] or {}
+            change_after = resource["change"]["after"] or {}
+
+            for field, value in change_before.items():
+                if field in LINE_FIELD_NAMES:
+                    continue  # don't care about line #s
+                if value != change_after.get(field):
+                    changes.append(field)
+
+            resource_changes_map[resource["address"]][TF_PLAN_RESOURCE_CHANGE_KEYS] = changes
 
     return resource_changes_map
 


Test patch:
diff --git a/tests/terraform/runner/extra_tf_plan_checks/security_group_rule_protocol_changed.py b/tests/terraform/runner/extra_tf_plan_checks/security_group_rule_protocol_changed.py
new file mode 100644
--- /dev/null
+++ b/tests/terraform/runner/extra_tf_plan_checks/security_group_rule_protocol_changed.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from typing import Any
+
+from checkov.common.models.enums import CheckResult, CheckCategories
+from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
+from checkov.terraform.plan_parser import TF_PLAN_RESOURCE_CHANGE_ACTIONS, TF_PLAN_RESOURCE_CHANGE_KEYS
+
+
+class SecurityGroupRuleProtocolChanged(BaseResourceCheck):
+    def __init__(self) -> None:
+        name = "Ensure security group rule protocol is not being changed"
+        id = "CUSTOM_CHANGE_1"
+        supported_resources = ("aws_security_group_rule",)
+        categories = (CheckCategories.GENERAL_SECURITY,)
+        super().__init__(name=name, id=id, categories=categories, supported_resources=supported_resources)
+
+    def scan_resource_conf(self, conf: dict[str, Any]) -> CheckResult:
+        actions = conf.get(TF_PLAN_RESOURCE_CHANGE_ACTIONS)
+        if isinstance(actions, list) and "update" in actions:
+            if "protocol" in conf.get(TF_PLAN_RESOURCE_CHANGE_KEYS):
+                self.details.append("some great details")
+                return CheckResult.FAILED
+        return CheckResult.PASSED
+
+
+check = SecurityGroupRuleProtocolChanged()
diff --git a/tests/terraform/runner/resources/plan_change_keys/tfplan.json b/tests/terraform/runner/resources/plan_change_keys/tfplan.json
new file mode 100644
--- /dev/null
+++ b/tests/terraform/runner/resources/plan_change_keys/tfplan.json
@@ -0,0 +1,228 @@
+{
+    "format_version": "1.1",
+    "terraform_version": "1.4.6",
+    "planned_values":
+    {
+        "root_module":
+        {
+            "resources":
+            [
+                {
+                    "address": "aws_security_group_rule.foo",
+                    "mode": "managed",
+                    "type": "aws_security_group_rule",
+                    "name": "foo",
+                    "provider_name": "registry.terraform.io/hashicorp/aws",
+                    "schema_version": 2,
+                    "values": {
+                        "cidr_blocks": null,
+                        "description": "foo",
+                        "from_port": 5432,
+                        "ipv6_cidr_blocks": null,
+                        "prefix_list_ids": [],
+                        "protocol": "tcp",
+                        "security_group_id": "sg-547cc4cd5f94bb695",
+                        "self": false,
+                        "source_security_group_id": "sg-8d21ab5963b0e7917",
+                        "timeouts": null,
+                        "to_port": 5433,
+                        "type": "ingress"
+                    },
+                    "sensitive_values": {
+                        "prefix_list_ids": []
+                    }
+                },
+                {
+                    "address": "aws_security_group_rule.bar",
+                    "mode": "managed",
+                    "type": "aws_security_group_rule",
+                    "name": "bar",
+                    "provider_name": "registry.terraform.io/hashicorp/aws",
+                    "schema_version": 2,
+                    "values": {
+                        "cidr_blocks": null,
+                        "description": "foo",
+                        "from_port": 8888,
+                        "ipv6_cidr_blocks": null,
+                        "prefix_list_ids": [],
+                        "protocol": "udp",
+                        "security_group_id": "sg-547cc4cd5f94bb696",
+                        "self": false,
+                        "source_security_group_id": "sg-8d21ab5963b0e7917",
+                        "timeouts": null,
+                        "to_port": 8888,
+                        "type": "ingress"
+                    },
+                    "sensitive_values": {
+                        "prefix_list_ids": []
+                    }
+                }
+            ]
+        }
+    },
+    "resource_changes":
+    [
+        {
+            "address": "aws_security_group_rule.foo",
+            "mode": "managed",
+            "type": "aws_security_group_rule",
+            "name": "foo",
+            "provider_name": "registry.terraform.io/hashicorp/aws",
+            "schema_version": 2,
+            "change":
+            {
+                "actions":
+                [
+                    "update"
+                ],
+                "before": {
+                    "cidr_blocks": [],
+                    "description": "foo",
+                    "from_port": 5432,
+                    "id": "sgrule-88888888",
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": [],
+                    "protocol": "tcp",
+                    "security_group_id": "sg-547cc4cd5f94bb695",
+                    "security_group_rule_id": null,
+                    "self": false,
+                    "source_security_group_id": "sg-8d21ab5963b0e7917",
+                    "timeouts": null,
+                    "to_port": 5432,
+                    "type": "ingress"
+                },
+                "after": {
+                    "cidr_blocks": [],
+                    "description": "foo",
+                    "from_port": 5432,
+                    "id": "sgrule-88888888",
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": [],
+                    "protocol": "tcp",
+                    "security_group_id": "sg-547cc4cd5f94bb695",
+                    "security_group_rule_id": "sgr-5a4b695164c564a8f",
+                    "self": false,
+                    "source_security_group_id": "sg-8d21ab5963b0e7917",
+                    "timeouts": null,
+                    "to_port": 5433,
+                    "type": "ingress"
+                },
+                "after_unknown": {},
+                "before_sensitive": {
+                    "cidr_blocks": [],
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": []
+                  },
+                "after_sensitive": {
+                    "cidr_blocks": [],
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": []
+                }
+            }
+        },
+        {
+            "address": "aws_security_group_rule.bar",
+            "mode": "managed",
+            "type": "aws_security_group_rule",
+            "name": "bar",
+            "provider_name": "registry.terraform.io/hashicorp/aws",
+            "schema_version": 2,
+            "change":
+            {
+                "actions":
+                [
+                    "update"
+                ],
+                "before": {
+                    "cidr_blocks": [],
+                    "description": "bar",
+                    "from_port": 8888,
+                    "id": "sgrule-88888888",
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": [],
+                    "protocol": "tcp",
+                    "security_group_id": "sg-547cc4cd5f94bb696",
+                    "security_group_rule_id": null,
+                    "self": false,
+                    "source_security_group_id": "sg-8d21ab5963b0e7917",
+                    "timeouts": null,
+                    "to_port": 8888,
+                    "type": "ingress"
+                },
+                "after": {
+                    "cidr_blocks": [],
+                    "description": "bar",
+                    "from_port": 8888,
+                    "id": "sgrule-88888888",
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": [],
+                    "protocol": "udp",
+                    "security_group_id": "sg-547cc4cd5f94bb696",
+                    "security_group_rule_id": "sgr-5a4b695164c564a90",
+                    "self": false,
+                    "source_security_group_id": "sg-8d21ab5963b0e7917",
+                    "timeouts": null,
+                    "to_port": 8888,
+                    "type": "ingress"
+                },
+                "after_unknown": {},
+                "before_sensitive": {
+                    "cidr_blocks": [],
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": []
+                  },
+                "after_sensitive": {
+                    "cidr_blocks": [],
+                    "ipv6_cidr_blocks": [],
+                    "prefix_list_ids": []
+                }
+            }
+        }
+    ],
+    "configuration":
+    {
+        "provider_config":
+        {
+            "aws":
+            {
+                "name": "aws"
+            }
+        },
+        "root_module":
+        {
+            "resources":
+            [
+                {
+                    "address": "aws_security_group_rule.foo",
+                    "mode": "managed",
+                    "type": "aws_security_group_rule",
+                    "name": "foo",
+                    "provider_config_key": "aws",
+                    "expressions":
+                    {
+                        "name":
+                        {
+                            "constant_value": "foo"
+                        }
+                    },
+                    "schema_version": 2
+                },
+                {
+                    "address": "aws_security_group_rule.bar",
+                    "mode": "managed",
+                    "type": "aws_security_group_rule",
+                    "name": "bar",
+                    "provider_config_key": "aws",
+                    "expressions":
+                    {
+                        "name":
+                        {
+                            "constant_value": "bar"
+                        }
+                    },
+                    "schema_version": 2
+                }
+            ]
+        }
+    }
+}
diff --git a/tests/terraform/runner/test_plan_runner.py b/tests/terraform/runner/test_plan_runner.py
--- a/tests/terraform/runner/test_plan_runner.py
+++ b/tests/terraform/runner/test_plan_runner.py
@@ -2,6 +2,7 @@
 import os
 import unittest
 from collections import defaultdict
+from copy import deepcopy
 from pathlib import Path
 
 
@@ -27,10 +28,10 @@
    {"db_connector": IgraphConnector}
 ])
 class TestRunnerValid(unittest.TestCase):
-
-    def setUp(self) -> None:
-        self.orig_checks = resource_registry.checks
-        self.db_connector = self.db_connector
+    @classmethod
+    def setUpClass(cls) -> None:
+        cls.orig_checks = deepcopy(resource_registry.checks)
+        cls.db_connector = cls.db_connector
 
     def test_runner_two_checks_only(self):
         current_dir = os.path.dirname(os.path.realpath(__file__))
@@ -834,8 +835,44 @@ def test___get_file_path__with_tf_definition_key_uses_correct_file_path(self):
         assert file_path == 'test'
         assert scanned_file == '/.'
 
+    def test_plan_change_keys(self):
+        # given
+        current_dir = Path(__file__).parent
+        tf_plan_path = current_dir / "resources/plan_change_keys/tfplan.json"
+        external_checks_dir = current_dir / "extra_tf_plan_checks"
+
+        # when
+        report = Runner().run(
+            root_folder=None,
+            files=[str(tf_plan_path)],
+            external_checks_dir=[str(external_checks_dir)],
+            runner_filter=RunnerFilter(framework=["terraform_plan"], checks=["CUSTOM_CHANGE_1"]),
+        )
+
+        # then
+        summary = report.get_summary()
+
+        passing_resources = {
+            'aws_security_group_rule.foo'
+        }
+        failing_resources = {
+            'aws_security_group_rule.bar',
+        }
+
+        passed_check_resources = {c.resource for c in report.passed_checks}
+        failed_check_resources = {c.resource for c in report.failed_checks}
+
+        self.assertEqual(summary["failed"], 1)
+        self.assertEqual(summary["passed"], 1)
+        self.assertEqual(summary["skipped"], 0)
+        self.assertEqual(summary["parsing_errors"], 0)
+        self.assertEqual(summary["resource_count"], 2)
+
+        self.assertEqual(passing_resources, passed_check_resources)
+        self.assertEqual(failing_resources, failed_check_resources)
+
     def tearDown(self) -> None:
-        resource_registry.checks = self.orig_checks
+        resource_registry.checks = deepcopy(self.orig_checks)
 
 
 if __name__ == "__main__":