Repository: bridgecrewio/checkov

Issue:
Ansible - support skipping CKV2_* checks
As per requested https://github.com/bridgecrewio/checkov/issues/4532#issuecomment-1445863421

This doesn't allow for supression:

`CKV2_ANSIBLE_1] Ensure that HTTPS url is used with uri`

Does anyone know how to get this working, unless I’m missing something obvious here ? a colleague is thinking something about `CKV2` as well ?

```
- name: do something
  # checkov:skip=CKV2_ANSIBLE_1: development ongoing
  uri:
```




Discussion:
> "Ensure that HTTPS url is used with uri"

Having a related issue with this rule, it triggers for template URLs in roles, like `"{{ some_url }}"`

Resolving patch:
diff --git a/checkov/ansible/runner.py b/checkov/ansible/runner.py
--- a/checkov/ansible/runner.py
+++ b/checkov/ansible/runner.py
@@ -5,7 +5,7 @@
 from checkov.ansible.checks.registry import registry
 from checkov.ansible.graph_builder.graph_components.resource_types import ResourceType
 from checkov.ansible.graph_builder.local_graph import AnsibleLocalGraph
-from checkov.ansible.utils import TASK_RESERVED_KEYWORDS, get_relevant_file_content
+from checkov.ansible.utils import get_relevant_file_content, build_definitions_context, generate_task_name
 from checkov.common.output.report import CheckType
 from checkov.common.util.consts import START_LINE, END_LINE
 from checkov.yaml_doc.runner import Runner as YamlRunner
@@ -51,8 +51,14 @@ def _parse_file(
         return None
 
     def get_resource(
-        self, file_path: str, key: str, supported_entities: Iterable[str],
-            start_line: int = -1, end_line: int = -1, graph_resource: bool = False) -> str:
+        self,
+        file_path: str,
+        key: str,
+        supported_entities: Iterable[str],
+        start_line: int = -1,
+        end_line: int = -1,
+        graph_resource: bool = False,
+    ) -> str:
         if not self.definitions or not isinstance(self.definitions, dict):
             return key
 
@@ -82,7 +88,7 @@ def generate_resource_name(
                                 )
                                 if resource_name is not None:
                                     return resource_name
-                            return self._generate_resource_name(task=task) or resource_key
+                            return generate_task_name(task=task) or resource_key
                 elif ResourceType.BLOCK in code_block:
                     resource_name = self._handle_block_tasks(
                         start_line=start_line,
@@ -92,7 +98,7 @@ def generate_resource_name(
                     if resource_name is not None:
                         return resource_name
                 else:
-                    return self._generate_resource_name(task=code_block) or resource_key
+                    return generate_task_name(task=code_block) or resource_key
 
         return resource_key
 
@@ -111,22 +117,13 @@ def _handle_block_tasks(
                     )
                     if resource_name is not None:
                         return resource_name
-                return self._generate_resource_name(task=block_task, prefix=prefix)
+                return generate_task_name(task=block_task, prefix=prefix)
 
         return None
 
-    def _generate_resource_name(self, task: dict[str, Any], prefix: str = "") -> str | None:
-        # grab the task name at the beginning before trying to find the actual module name
-        task_name = task.get("name") or "unknown"
-
-        for name in task:
-            if name in TASK_RESERVED_KEYWORDS:
-                continue
-
-            if prefix:
-                # if the task is found in a block, then prefix the module name with 'block'
-                name = f"{prefix}{name}"
-
-            return f"{ResourceType.TASKS}.{name}.{task_name}"
-
-        return None
+    def build_definitions_context(
+        self,
+        definitions: dict[str, dict[str, Any] | list[dict[str, Any]]],
+        definitions_raw: dict[str, list[tuple[int, str]]],
+    ) -> dict[str, dict[str, Any]]:
+        return build_definitions_context(definitions=definitions, definitions_raw=definitions_raw)
diff --git a/checkov/ansible/utils.py b/checkov/ansible/utils.py
--- a/checkov/ansible/utils.py
+++ b/checkov/ansible/utils.py
@@ -1,10 +1,14 @@
 from __future__ import annotations
 
+import logging
 import re
 from pathlib import Path
 from typing import Any
 
+from checkov.ansible.graph_builder.graph_components.resource_types import ResourceType
 from checkov.common.parsers.yaml.parser import parse
+from checkov.common.util.consts import START_LINE, END_LINE
+from checkov.common.util.suppression import collect_suppressions_for_context
 
 TASK_NAME_PATTERN = re.compile(r"^\s*-\s+name:\s+", re.MULTILINE)
 
@@ -53,6 +57,8 @@
     "when",
 }
 
+logger = logging.getLogger(__name__)
+
 
 def get_scannable_file_paths(root_folder: str | Path) -> set[Path]:
     """Finds yaml files"""
@@ -89,3 +95,90 @@ def parse_file(
         return content
 
     return None
+
+
+def generate_task_name(task: dict[str, Any], prefix: str = "") -> str | None:
+    # grab the task name at the beginning before trying to find the actual module name
+    task_name = task.get("name") or "unknown"
+
+    for name in task:
+        if name in TASK_RESERVED_KEYWORDS:
+            continue
+
+        if prefix:
+            # if the task is found in a block, then prefix the module name with 'block'
+            name = f"{prefix}{name}"
+
+        return f"{ResourceType.TASKS}.{name}.{task_name}"
+
+    return None
+
+
+def build_definitions_context(
+    definitions: dict[str, dict[str, Any] | list[dict[str, Any]]],
+    definitions_raw: dict[str, list[tuple[int, str]]],
+) -> dict[str, dict[str, Any]]:
+    definitions_context: dict[str, dict[str, Any]] = {}
+
+    for file_path, definition in definitions.items():
+        file_path_context: dict[str, Any] = {}
+        definition_raw = definitions_raw[file_path]
+
+        if not isinstance(definition, list):
+            logger.info(f"File {file_path} has the wrong type {type(definition)}")
+            continue
+
+        for code_block in definition:
+            if ResourceType.TASKS in code_block:
+                for task in code_block[ResourceType.TASKS]:
+                    _process_blocks(definition_raw=definition_raw, file_path_context=file_path_context, task=task)
+            else:
+                _process_blocks(definition_raw=definition_raw, file_path_context=file_path_context, task=code_block)
+
+        definitions_context[file_path] = file_path_context
+
+    return definitions_context
+
+
+def _process_blocks(
+    definition_raw: list[tuple[int, str]],
+    file_path_context: dict[str, Any],
+    task: Any,
+    prefix: str = "",
+) -> None:
+    """Checks for possible block usage"""
+
+    if not task or not isinstance(task, dict):
+        return
+
+    if ResourceType.BLOCK in task and isinstance(task[ResourceType.BLOCK], list):
+        prefix += f"{ResourceType.BLOCK}."  # with each nested level an extra block prefix is added
+        block_name = f"{prefix}.{task.get('name') or 'unknown'}"
+        resource_context = _create_resource_context(definition_raw=definition_raw, resource=task)
+        file_path_context[block_name] = resource_context
+
+        for block_task in task[ResourceType.BLOCK]:
+            _process_blocks(
+                definition_raw=definition_raw, file_path_context=file_path_context, task=block_task, prefix=prefix
+            )
+    else:
+        resource_context = _create_resource_context(definition_raw=definition_raw, resource=task)
+        task_name = generate_task_name(task=task, prefix=prefix)
+        if task_name:
+            file_path_context[task_name] = resource_context
+
+
+def _create_resource_context(definition_raw: list[tuple[int, str]], resource: dict[str, Any]) -> dict[str, Any]:
+    """Creates the resource context block"""
+
+    start_line = resource[START_LINE]
+    end_line = resource[END_LINE]
+    code_lines = definition_raw[start_line - 1 : end_line - 1]  # lines start with index 0
+    skipped_checks = collect_suppressions_for_context(code_lines=code_lines)
+
+    return {
+        "start_line": start_line,
+        "end_line": end_line - 1,
+        "code_lines": code_lines,
+        "skipped_checks": skipped_checks,
+    }
diff --git a/checkov/common/runners/object_runner.py b/checkov/common/runners/object_runner.py
--- a/checkov/common/runners/object_runner.py
+++ b/checkov/common/runners/object_runner.py
@@ -11,6 +11,7 @@
 from typing_extensions import TypedDict
 
 from checkov.common.checks_infra.registry import get_graph_checks_registry
+from checkov.common.models.enums import CheckResult
 from checkov.common.typing import LibraryGraphConnector
 from checkov.common.graph.graph_builder import CustomAttributes
 from checkov.common.output.github_actions_record import GithubActionsRecord
@@ -26,6 +27,7 @@
 
 if TYPE_CHECKING:
     from checkov.common.checks.base_check_registry import BaseCheckRegistry
+    from checkov.common.graph.checks_infra.base_check import BaseGraphCheck
     from checkov.common.runners.graph_builder.local_graph import ObjectLocalGraph
 
 
@@ -131,6 +133,8 @@ def run(
                     files_to_load = [os.path.join(root, f_name) for f_name in f_names]
                     self._load_files(files_to_load=files_to_load)
 
+            self.context = self.build_definitions_context(definitions=self.definitions, definitions_raw=self.definitions_raw)
+
             if CHECKOV_CREATE_GRAPH and self.graph_registry and self.graph_manager:
                 logging.info(f"Creating {self.source} graph")
                 local_graph = self.graph_manager.build_graph_from_definitions(
@@ -244,6 +248,9 @@ def add_graph_check_results(self, report: Report, runner_filter: RunnerFilter) -
 
                 start_line = entity[START_LINE]
                 end_line = entity[END_LINE]
+                code_block = self.get_code_block(entity=entity)
+
+                self.add_inline_suppression(check=check, entity=entity, check_result=clean_check_result)
 
                 if self.check_type == CheckType.GITHUB_ACTIONS:
                     if entity.get(CustomAttributes.BLOCK_NAME) == 'permissions' and start_line == 0 and end_line == 0:
@@ -262,7 +269,7 @@ def add_graph_check_results(self, report: Report, runner_filter: RunnerFilter) -
                         bc_check_id=check.bc_id,
                         check_name=check.name,
                         check_result=clean_check_result,
-                        code_block=self.definitions_raw[entity_file_path][start_line - 1:end_line + 1],
+                        code_block=code_block,
                         file_path=f"/{os.path.relpath(entity_file_path, root_folder)}",
                         file_line_range=[start_line, end_line + 1],
                         resource=entity[CustomAttributes.ID],
@@ -281,7 +288,7 @@ def add_graph_check_results(self, report: Report, runner_filter: RunnerFilter) -
                         bc_check_id=check.bc_id,
                         check_name=check.name,
                         check_result=clean_check_result,
-                        code_block=self.definitions_raw[entity_file_path][start_line - 1:end_line + 1],
+                        code_block=code_block,
                         file_path=f"/{os.path.relpath(entity_file_path, root_folder)}",
                         file_line_range=[start_line, end_line + 1],
                         resource=entity[CustomAttributes.ID],
@@ -316,6 +323,61 @@ def _change_files_path_to_relative(report: Report) -> None:
             record.file_path = record.file_path.replace(os.getcwd(), "")
             record.resource = record.resource.replace(os.getcwd(), "")
 
+    def build_definitions_context(
+        self,
+        definitions: dict[str, dict[str, Any] | list[dict[str, Any]]],
+        definitions_raw: dict[str, list[tuple[int, str]]],
+    ) -> dict[str, dict[str, Any]]:
+        # if needed, should be overridden in the actual runner class
+        return {}
+
+    def get_code_block(self, entity: dict[str, Any]) -> list[tuple[int, str]]:
+        """Returns the code block either from context or definitions_raw"""
+
+        code_block: list[tuple[int, str]] = []
+
+        entity_file_path = entity[CustomAttributes.FILE_PATH]
+
+        if self.context:
+            # not all runners have the 'context' attribute populated
+            entity_id = entity[CustomAttributes.ID]
+            entity_context = self.context[entity_file_path].get(entity_id)
+
+            if entity_context:
+                code_block = entity_context.get("code_lines")
+            else:
+                logging.info(f"Could not find context for resource {entity_id} in file {entity_file_path}")
+
+        if not code_block:
+            # fallback, if context extraction failed
+            start_line = entity[START_LINE]
+            end_line = entity[END_LINE]
+            code_block = self.definitions_raw[entity_file_path][start_line - 1:end_line + 1]
+
+        return code_block
+
+    def add_inline_suppression(self, check: BaseGraphCheck, entity: dict[str, Any], check_result: _CheckResult) -> None:
+        """Adjusts check result, if inline suppressed"""
+
+        if self.context:
+            # not all runners have the 'context' attribute populated
+            entity_file_path = entity[CustomAttributes.FILE_PATH]
+            entity_id = entity[CustomAttributes.ID]
+            entity_context = self.context[entity_file_path].get(entity_id)
+
+            if entity_context:
+                skipped_check = next(
+                    (
+                        skipped_check
+                        for skipped_check in entity_context.get("skipped_checks", [])
+                        if skipped_check["id"] in (check.id, check.bc_id)
+                    ),
+                    None,
+                )
+                if skipped_check:
+                    check_result["result"] = CheckResult.SKIPPED
+                    check_result["suppress_comment"] = skipped_check.get("suppress_comment", "")
+
     def _get_triggers(self, definition: dict[str, Any]) -> set[str]:
         triggers_set = set()
         triggers = definition.get("on")


Test patch:
diff --git a/tests/ansible/examples/skip.yml b/tests/ansible/examples/skip.yml
--- a/tests/ansible/examples/skip.yml
+++ b/tests/ansible/examples/skip.yml
@@ -20,3 +20,12 @@
         security_group: sg-04acc4e02a5b71244
         image_id: "{{ ami_latest.image_id }}"
         state: running
+
+    - name: http
+      #checkov:skip=CKV2_ANSIBLE_1
+      uri:
+        url: http://www.example.com
+        return_content: yes
+      register: this
+      failed_when: "'AWESOME' not in this.content"
+
diff --git a/tests/ansible/test_runner.py b/tests/ansible/test_runner.py
--- a/tests/ansible/test_runner.py
+++ b/tests/ansible/test_runner.py
@@ -107,7 +107,9 @@ def test_runner_skipping_check(graph_connector):
 
     # when
     report = Runner(db_connector=graph_connector()).run(
-        root_folder="", files=[str(test_file)], runner_filter=RunnerFilter(checks=["CKV_AWS_88", "CKV_AWS_135"])
+        root_folder="",
+        files=[str(test_file)],
+        runner_filter=RunnerFilter(checks=["CKV2_ANSIBLE_1", "CKV_AWS_88", "CKV_AWS_135"]),
     )
 
     # then
@@ -115,15 +117,17 @@ def test_runner_skipping_check(graph_connector):
 
     assert summary["passed"] == 0
     assert summary["failed"] == 2
-    assert summary["skipped"] == 2
+    assert summary["skipped"] == 3
     assert summary["parsing_errors"] == 0
 
-    assert {check.check_id for check in report.skipped_checks} == {"CKV_AWS_88", "CKV_AWS_135"}
+    assert {check.check_id for check in report.skipped_checks} == {"CKV2_ANSIBLE_1", "CKV_AWS_88", "CKV_AWS_135"}
 
-    check_88 = next(check for check in report.skipped_checks if check.check_id == "CKV_AWS_88")
-    check_135 = next(check for check in report.skipped_checks if check.check_id == "CKV_AWS_135")
-    assert check_88.resource == "tasks.amazon.aws.ec2_instance.Launch ec2 instances 2"
-    assert check_135.resource == "tasks.amazon.aws.ec2_instance.Launch ec2 instances 1"
+    ansible_1 = next(check for check in report.skipped_checks if check.check_id == "CKV2_ANSIBLE_1")
+    aws_88 = next(check for check in report.skipped_checks if check.check_id == "CKV_AWS_88")
+    aws_135 = next(check for check in report.skipped_checks if check.check_id == "CKV_AWS_135")
+    assert ansible_1.resource == "tasks.uri.http"
+    assert aws_88.resource == "tasks.amazon.aws.ec2_instance.Launch ec2 instances 2"
+    assert aws_135.resource == "tasks.amazon.aws.ec2_instance.Launch ec2 instances 1"
 
 
 @pytest.mark.parametrize(
diff --git a/tests/ansible/test_utils.py b/tests/ansible/test_utils.py
new file mode 100644
--- /dev/null
+++ b/tests/ansible/test_utils.py
@@ -0,0 +1,47 @@
+from pathlib import Path
+
+from checkov.ansible.runner import Runner
+from checkov.ansible.utils import build_definitions_context
+
+EXAMPLES_DIR = Path(__file__).parent / "examples"
+
+
+def test_build_definitions_context():
+    # given
+    file_path = str(EXAMPLES_DIR / "skip.yml")
+    definitions, definitions_raw = {}, {}
+    definitions[file_path], definitions_raw[file_path] = Runner()._parse_file(f=file_path)
+
+    # when
+    context = build_definitions_context(definitions=definitions, definitions_raw=definitions_raw)
+
+    assert len(context) == 1
+
+    definition_context = next(iter(context.values()))
+    assert len(definition_context) == 3
+
+    task_context = definition_context["tasks.uri.http"]
+
+    assert task_context["start_line"] == 24
+    assert task_context["end_line"] == 31
+    assert task_context["code_lines"] == [
+        (24, "    - name: http\n"),
+        (25, "      #checkov:skip=CKV2_ANSIBLE_1\n"),
+        (26, "      uri:\n"),
+        (27, "        url: http://www.example.com\n"),
+        (28, "        return_content: yes\n"),
+        (29, "      register: this\n"),
+        (30, "      failed_when: \"'AWESOME' not in this.content\"\n"),
+        (31, "\n"),
+    ]
+
+    for skip in task_context["skipped_checks"]:
+        skip.pop("bc_id", None)  # depending on the test order they are set or not
+
+    assert task_context["skipped_checks"] == [
+        {
+            "id": "CKV2_ANSIBLE_1",
+            "line_number": 25,
+            "suppress_comment": "No comment provided",
+        }
+    ]