Repository: bridgecrewio/checkov

Issue:
How to Check if a Resource is being Deleted on Terraform Plan
**Describe the issue**
I want to check if a resource is being deleted on the terraform plan.

**Examples**
Snippet from tfplan.json
``` json
{
    "format_version": "1.1",
    "terraform_version": "1.2.3",
    "planned_values": {
    }
    "resource_changes": [
        .
        .
        .
        {
            "address": "azuredevops_group_membership.admins",
            "mode": "managed",
            "type": "azuredevops_group_membership",
            "name": "admins",
            "provider_name": "registry.terraform.io/microsoft/azuredevops",
            "change": {
                "actions": [
                    "delete"
                ],
                .
                .
                .
            }
        }
    ],
    .
    .
    .
}
```

I thought something like this could work, 

```python
from checkov.common.models.enums import CheckResult, CheckCategories
#from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.terraform.checks.resource.base_resource_value_check import BaseResourceValueCheck

class NoCriticalResourceDestroyedOnPlan(BaseResourceValueCheck):
    def __init__(self):
        name = "Ensures no critical resource is destroyed on plan"
        id = "CKV_ADO_800"
        supported_resources = ['azuredevops_group_membership'] #only for testing
        categories = [CheckCategories.GENERAL_SECURITY]
        super().__init__(name=name, id=id, categories=categories, supported_resources=supported_resources)

    def get_inspected_key(self):
        return "resource_changes/[1]/change/actions/[0]" #something like this
    
    def get_expected_values(self):
        return "no-op"


check = NoCriticalResourceDestroyedOnPlan()
```
but even though Checkov can scan terraform plan files, apparently it can't find the deleted resource on the plan, since the **conf** returns nothing.
```
[ secrets framework ]: 100%|████████████████████|[1/1], Current File Scanned=tfplan.json
[ json framework ]:   0%|                    |[0/1], Current File Scanned=tfplan.json                      Traceback (most recent call last):
  File "C:\Python39\Scripts\checkov.cmd", line 44, in <module>[0/1], Current File Scanned=..\tfplan.json
    sys.exit(run())ion framework ]:   0%|                    |[0/1], Current File Scanned=..\tfplan.json
  File "C:\Python39\lib\site-packages\checkov\main.py", line 308, in run
    scan_reports = runner_registry.run(external_checks_dir=external_checks_dir, files=config.file,plan.json
  File "C:\Python39\lib\site-packages\checkov\common\runners\runner_registry.py", line 87, in run
    for scan_report in reports:
  File "C:\Python39\lib\concurrent\futures\_base.py", line 608, in result_iterator
    yield fs.pop().result()
  File "C:\Python39\lib\concurrent\futures\_base.py", line 438, in result
    return self.__get_result()
  File "C:\Python39\lib\concurrent\futures\_base.py", line 390, in __get_result
    raise self._exception
  File "C:\Python39\lib\concurrent\futures\thread.py", line 52, in run
    result = self.fn(*self.args, **self.kwargs)
  File "C:\Python39\lib\site-packages\checkov\common\runners\runner_registry.py", line 77, in _parallel_run
    return runner.run(
  File "C:\Python39\lib\site-packages\checkov\common\runners\object_runner.py", line 86, in run
    skipped_checks = collect_suppressions_for_context(definitions_raw[file_path])
  File "C:\Python39\lib\site-packages\checkov\common\util\suppression.py", line 36, in collect_suppressions_for_context
    for line_number, line_text in code_lines:
TypeError: 'NoneType' object is not iterable
```

**Version (please complete the following information):**
 - Checkov Version 2.1.18

**Additional context**
As @rdalbuquerque asked on the Slack community, is there a way to do this with Checkov? We want to check if critical resources are being deleted. Thanks in advance! 



Discussion:

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
@@ -1,16 +1,20 @@
+from __future__ import annotations
+
 import itertools
 from typing import Optional, Tuple, Dict, List, Any
 
 from checkov.common.parsers.node import DictNode, ListNode
 from checkov.terraform.context_parsers.tf_plan import parse
 
-simple_types = (str, int, float, bool)
+SIMPLE_TYPES = (str, int, float, bool)
+TF_PLAN_RESOURCE_ADDRESS = "__address__"
+TF_PLAN_RESOURCE_CHANGE_ACTIONS = "__change_actions__"
 
 
 def _is_simple_type(obj: Any) -> bool:
     if obj is None:
         return True
-    if isinstance(obj, simple_types):
+    if isinstance(obj, SIMPLE_TYPES):
         return True
     return False
 
@@ -78,11 +82,14 @@ def _hclify(obj: DictNode, conf: Optional[DictNode] = None, parent_key: Optional
     return ret_dict
 
 
-def _prepare_resource_block(resource: DictNode, conf: Optional[DictNode]) -> Tuple[Dict[str, Dict[str, Any]], bool]:
+def _prepare_resource_block(
+    resource: DictNode, conf: Optional[DictNode], resource_changes: dict[str, dict[str, Any]]
+) -> tuple[dict[str, dict[str, Any]], bool]:
     """hclify resource if pre-conditions met.
 
     :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
@@ -99,13 +106,23 @@ def _prepare_resource_block(resource: DictNode, conf: Optional[DictNode]) -> Tup
     # and where *_module resources don't have values field
     if mode == "managed" and "values" in resource:
         expressions = conf.get("expressions") if conf else None
-        resource_block[resource["type"]][resource.get("name", "default")] = _hclify(resource["values"], expressions)
-        resource_block[resource["type"]][resource.get("name", "default")]["__address__"] = resource.get("address")
+
+        resource_conf = _hclify(resource["values"], expressions)
+        resource_address = resource.get("address")
+        resource_conf[TF_PLAN_RESOURCE_ADDRESS] = resource_address
+
+        changes = resource_changes.get(resource_address)
+        if changes:
+            resource_conf[TF_PLAN_RESOURCE_CHANGE_ACTIONS] = changes.get("change", {}).get("actions") or []
+
+        resource_block[resource["type"]][resource.get("name", "default")] = resource_conf
         prepared = True
     return resource_block, prepared
 
 
-def _find_child_modules(child_modules: ListNode) -> List[Dict[str, Dict[str, Any]]]:
+def _find_child_modules(
+    child_modules: ListNode, resource_changes: dict[str, dict[str, Any]]
+) -> List[Dict[str, Dict[str, Any]]]:
     """
     Find all child modules if any. Including any amount of nested child modules.
     :type: child_modules: list of tf child_module objects
@@ -115,16 +132,35 @@ def _find_child_modules(child_modules: ListNode) -> List[Dict[str, Dict[str, Any
     for child_module in child_modules:
         if child_module.get("child_modules", []):
             nested_child_modules = child_module.get("child_modules", [])
-            nested_blocks = _find_child_modules(nested_child_modules)
+            nested_blocks = _find_child_modules(nested_child_modules, resource_changes)
             for resource in nested_blocks:
                 resource_blocks.append(resource)
         for resource in child_module.get("resources", []):
-            resource_block, prepared = _prepare_resource_block(resource, None)
+            resource_block, prepared = _prepare_resource_block(
+                resource=resource,
+                conf=None,
+                resource_changes=resource_changes,
+            )
             if prepared is True:
                 resource_blocks.append(resource_block)
     return resource_blocks
 
 
+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
+        }
+
+    return resource_changes_map
+
+
 def parse_tf_plan(tf_plan_file: str, out_parsing_errors: Dict[str, str]) -> Tuple[Optional[Dict[str, Any]], Optional[List[Tuple[int, str]]]]:
     """
     :type tf_plan_file: str - path to plan file
@@ -134,6 +170,9 @@ def parse_tf_plan(tf_plan_file: str, out_parsing_errors: Dict[str, str]) -> Tupl
     template, template_lines = parse(tf_plan_file, out_parsing_errors)
     if not template:
         return None, None
+
+    resource_changes = _get_resource_changes(template=template)
+
     for resource in template.get("planned_values", {}).get("root_module", {}).get("resources", []):
         conf = next(
             (
@@ -143,13 +182,17 @@ def parse_tf_plan(tf_plan_file: str, out_parsing_errors: Dict[str, str]) -> Tupl
             ),
             None,
         )
-        resource_block, prepared = _prepare_resource_block(resource, conf)
+        resource_block, prepared = _prepare_resource_block(
+            resource=resource,
+            conf=conf,
+            resource_changes=resource_changes,
+        )
         if prepared is True:
             tf_definition["resource"].append(resource_block)
     child_modules = template.get("planned_values", {}).get("root_module", {}).get("child_modules", [])
     # Terraform supports modules within modules so we need to search
     # in nested modules to find all resource blocks
-    resource_blocks = _find_child_modules(child_modules)
+    resource_blocks = _find_child_modules(child_modules, resource_changes)
     for resource in resource_blocks:
         tf_definition["resource"].append(resource)
     return tf_definition, template_lines
diff --git a/checkov/terraform/plan_utils.py b/checkov/terraform/plan_utils.py
--- a/checkov/terraform/plan_utils.py
+++ b/checkov/terraform/plan_utils.py
@@ -5,7 +5,7 @@
 import dpath
 
 from checkov.terraform.context_parsers.registry import parser_registry
-from checkov.terraform.plan_parser import parse_tf_plan
+from checkov.terraform.plan_parser import parse_tf_plan, TF_PLAN_RESOURCE_ADDRESS
 from checkov.common.runners.base_runner import filter_ignored_paths
 from checkov.runner_filter import RunnerFilter
 from checkov.common.parsers.node import DictNode
@@ -85,6 +85,6 @@ def get_entity_context(definitions, definitions_raw, definition_path, full_file_
                 entity_context['end_line'] = resource_defintion['end_line'][0]
                 entity_context["code_lines"] = definitions_raw[full_file_path][
                                                entity_context["start_line"]: entity_context["end_line"]]
-                entity_context['address'] = resource_defintion['__address__']
+                entity_context['address'] = resource_defintion[TF_PLAN_RESOURCE_ADDRESS]
                 return entity_context
     return entity_context


Test patch:
diff --git a/tests/terraform/runner/extra_tf_plan_checks/__init__.py b/tests/terraform/runner/extra_tf_plan_checks/__init__.py
new file mode 100644
diff --git a/tests/terraform/runner/extra_tf_plan_checks/secret_not_deleted.py b/tests/terraform/runner/extra_tf_plan_checks/secret_not_deleted.py
new file mode 100644
--- /dev/null
+++ b/tests/terraform/runner/extra_tf_plan_checks/secret_not_deleted.py
@@ -0,0 +1,25 @@
+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
+
+
+class KmsKeyNotDeleted(BaseResourceCheck):
+    def __init__(self) -> None:
+        name = "Ensure Secret is not deleted"
+        id = "CUSTOM_DELETE_1"
+        supported_resources = ("aws_secretsmanager_secret",)
+        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 "delete" in actions:
+            return CheckResult.FAILED
+        return CheckResult.PASSED
+
+
+scanner = KmsKeyNotDeleted()
diff --git a/tests/terraform/runner/extra_tf_plan_checks/secret_not_deleted.yaml b/tests/terraform/runner/extra_tf_plan_checks/secret_not_deleted.yaml
new file mode 100644
--- /dev/null
+++ b/tests/terraform/runner/extra_tf_plan_checks/secret_not_deleted.yaml
@@ -0,0 +1,11 @@
+metadata:
+  name: "Ensure Secret is not deleted"
+  id: "CUSTOM_DELETE_2"
+  category: "GENERAL_SECURITY"
+definition:
+  cond_type: attribute
+  resource_types:
+    - aws_secretsmanager_secret
+  attribute: __change_actions__
+  operator: not_contains
+  value: delete
diff --git a/tests/terraform/runner/resources/plan_with_deleted_resources/tfplan.json b/tests/terraform/runner/resources/plan_with_deleted_resources/tfplan.json
new file mode 100644
--- /dev/null
+++ b/tests/terraform/runner/resources/plan_with_deleted_resources/tfplan.json
@@ -0,0 +1,105 @@
+{
+  "format_version": "1.1",
+  "terraform_version": "1.2.4",
+  "planned_values": {
+    "root_module": {
+      "resources": [
+        {
+          "address": "aws_secretsmanager_secret.default",
+          "mode": "managed",
+          "type": "aws_secretsmanager_secret",
+          "name": "default",
+          "provider_name": "registry.terraform.io/hashicorp/aws",
+          "schema_version": 0,
+          "values": {
+            "description": null,
+            "force_overwrite_replica_secret": false,
+            "kms_key_id": null,
+            "name": "test",
+            "recovery_window_in_days": 30,
+            "tags": null
+          },
+          "sensitive_values": {
+            "replica": [],
+            "rotation_rules": [],
+            "tags_all": {}
+          }
+        }
+      ]
+    }
+  },
+  "resource_changes": [
+    {
+      "address": "aws_secretsmanager_secret.default",
+      "mode": "managed",
+      "type": "aws_secretsmanager_secret",
+      "name": "default",
+      "provider_name": "registry.terraform.io/hashicorp/aws",
+      "change": {
+        "actions": [
+          "create",
+          "delete"
+        ],
+        "before": null,
+        "after": {
+          "description": null,
+          "force_overwrite_replica_secret": false,
+          "kms_key_id": null,
+          "name": "test",
+          "recovery_window_in_days": 30,
+          "tags": null
+        },
+        "after_unknown": {
+          "arn": true,
+          "id": true,
+          "name_prefix": true,
+          "policy": true,
+          "replica": true,
+          "rotation_enabled": true,
+          "rotation_lambda_arn": true,
+          "rotation_rules": true,
+          "tags_all": true
+        },
+        "before_sensitive": false,
+        "after_sensitive": {
+          "replica": [],
+          "rotation_rules": [],
+          "tags_all": {}
+        }
+      }
+    }
+  ],
+  "configuration": {
+    "provider_config": {
+      "aws": {
+        "name": "aws",
+        "full_name": "registry.terraform.io/hashicorp/aws",
+        "expressions": {
+          "profile": {
+            "constant_value": "dev2"
+          },
+          "region": {
+            "constant_value": "us-west-2"
+          }
+        }
+      }
+    },
+    "root_module": {
+      "resources": [
+        {
+          "address": "aws_secretsmanager_secret.default",
+          "mode": "managed",
+          "type": "aws_secretsmanager_secret",
+          "name": "default",
+          "provider_config_key": "aws",
+          "expressions": {
+            "name": {
+              "constant_value": "test"
+            }
+          },
+          "schema_version": 0
+        }
+      ]
+    }
+  }
+}
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
@@ -586,6 +586,27 @@ def test_runner_ignore_lifecycle_checks(self):
         # then
         self.assertEqual(len(report.failed_checks), 0)
 
+    def test_runner_extra_check(self):
+        # given
+        current_dir = Path(__file__).parent
+        tf_dir_path = str(current_dir / "resources/plan_with_deleted_resources")
+        extra_checks_dir_path = [str(current_dir / "extra_tf_plan_checks")]
+
+        # when
+        report = Runner().run(
+            root_folder=tf_dir_path,
+            external_checks_dir=extra_checks_dir_path,
+            runner_filter=RunnerFilter(checks=["CUSTOM_DELETE_1", "CUSTOM_DELETE_2"])
+        )
+
+        # then
+        summary = report.get_summary()
+        self.assertEqual(summary["failed"], 2)
+
+        resource_ids = [check.resource for check in report.failed_checks]
+        self.assertCountEqual(resource_ids,["aws_secretsmanager_secret.default", "aws_secretsmanager_secret.default"])
+
+
     def tearDown(self) -> None:
         resource_registry.checks = self.orig_checks