diff --git a/README.md b/README.md index 8a6f8b25d..e2a9019a5 100644 --- a/README.md +++ b/README.md @@ -29,4 +29,4 @@ Please follow our [Getting Started Guide](https://bancolombia.github.io/devsecop # How can I help? -Review the issues, we hear new ideas. Read more [Contributing](https://github.com/bancolombia/devsecops-engine-tools/blob/trunk/docs/CONTRIBUTING.md) +Review the issues, we hear new ideas. Read more [Contributing](https://github.com/bancolombia/devsecops-engine-tools/blob/trunk/docs/CONTRIBUTING.md) \ No newline at end of file diff --git a/docs/Docusaurus/docs/life_cycle/getting_started.md b/docs/Docusaurus/docs/life_cycle/getting_started.md index 6026d80e6..036bee46b 100644 --- a/docs/Docusaurus/docs/life_cycle/getting_started.md +++ b/docs/Docusaurus/docs/life_cycle/getting_started.md @@ -45,6 +45,10 @@ For more information about structure remote config visit [Structure Remote Confi KICS Free + + + CONFTEST + Free ENGINE_DAST diff --git a/docs/Docusaurus/docs/modules/engine_core.md b/docs/Docusaurus/docs/modules/engine_core.md index a6dd7cefd..ea22448f1 100644 --- a/docs/Docusaurus/docs/modules/engine_core.md +++ b/docs/Docusaurus/docs/modules/engine_core.md @@ -210,7 +210,7 @@ Configuration of the driven adapters in the main layer and management of on/off }, "ENGINE_IAC": { "ENABLED": true, - "TOOL": "CHECKOV|KUBESCAPE|KICS", + "TOOL": "CHECKOV|KUBESCAPE|KICS|CONFTEST", "PRIORITY": "STANDARD|DISCREET" }, "ENGINE_CONTAINER": { diff --git a/docs/Docusaurus/docs/modules/engine_sast/engine_iac.md b/docs/Docusaurus/docs/modules/engine_sast/engine_iac.md index 90438dd44..3e84a784d 100755 --- a/docs/Docusaurus/docs/modules/engine_sast/engine_iac.md +++ b/docs/Docusaurus/docs/modules/engine_sast/engine_iac.md @@ -170,6 +170,42 @@ Main configuration file that defines scanning behavior, tool versions, and secur }, "KUBESCAPE": { "VERSION": "3.0.11" + }, + "CONFTEST": { + "VERSION": "0.56.0", + "POLICY_PATH": "policy", + "USE_EXTERNAL_CHECKS_DIR": false, + "EXTERNAL_DIR_OWNER": "", + "EXTERNAL_DIR_REPOSITORY": "", + "EXTERNAL_DIR_POLICIES_PATH": "policy", + "APP_ID_GITHUB": "", + "INSTALLATION_ID_GITHUB": "", + "DEFAULT_SEVERITY": "high", + "DEFAULT_CATEGORY": "vulnerability", + "RULES": { + "RULES_N8N": { + "CONF_N8N_BC_1": { + "severity": "Critical", + "category": "Vulnerability" + }, + "CONF_N8N_BC_2": { + "severity": "Medium", + "category": "Vulnerability" + }, + "CONF_N8N_BC_3": { + "severity": "High", + "category": "Vulnerability" + }, + "CONF_N8N_BC_4": { + "severity": "Low", + "category": "Compliance" + }, + "CONF_N8N_BC_5": { + "severity": "Critical", + "category": "Vulnerability" + } + } + } } } ``` @@ -232,6 +268,21 @@ Main configuration file that defines scanning behavior, tool versions, and secur ##### Kubescape Tool Configuration - **VERSION**: Kubescape version to use (e.g., `"3.0.11"`) +##### Conftest Tool Configuration +- **VERSION**: Conftest version to download (e.g., `"0.56.0"`) +- **POLICY_PATH**: Local path to the directory containing `.rego` policy files (default `"policy"`) +- **External Policy Configuration** (via GitHub App): + - `USE_EXTERNAL_CHECKS_DIR`: Download policies from a GitHub repository (`true`/`false`) + - `EXTERNAL_DIR_OWNER`: GitHub organisation or user owning the policy repository + - `EXTERNAL_DIR_REPOSITORY`: Repository name containing the policies + - `EXTERNAL_DIR_POLICIES_PATH`: Path inside the repository where policies are stored + - `APP_ID_GITHUB`: GitHub App ID used for authentication + - `INSTALLATION_ID_GITHUB`: GitHub App installation ID +- **Default Values**: + - `DEFAULT_SEVERITY`: Fallback severity when a rule has no entry in `RULES` (e.g., `"high"`) + - `DEFAULT_CATEGORY`: Fallback category when a rule has no entry in `RULES` (e.g., `"vulnerability"`) +- **RULES**: Rule metadata keyed by platform group then rule ID (see [Security Rules Configuration](#security-rules-configuration) below) + ##### Security Rules Configuration Each tool contains rule sets organized by technology: @@ -256,6 +307,7 @@ Each tool contains rule sets organized by technology: - **RULES_OPENAPI**: API security rules for OpenAPI specifications - **RULES_SERVERLESS**: Serverless rules for AWS Lambda specifications - **RULES_BICEP**: Azure Bicep security rules for Azure Cloud infrastructure +- **RULES_N8N**: n8n workflow security rules (authentication, transport security, dangerous nodes, error handling, hardcoded credentials) In the RULES section of each platform (RULES_DOCKER, RULES_K8S, RULES_CLOUDFORMATION, etc.), the body is empty. Example “RULES_DOCKER” {}, the tool executes all rules associated with it. @@ -350,7 +402,7 @@ Defines exclusion rules for repositories and specific security checks. ## Main Responsibilities -- **IaC Security Orchestration:** Executes IaC security tools (Checkov, KICS, Kubescape) on infrastructure code +- **IaC Security Orchestration:** Executes IaC security tools (Checkov, KICS, Kubescape, Conftest) on infrastructure code - **Configuration Management:** Loads and processes scan configurations and exclusions from remote repositories - **Folder and File Discovery:** Identifies relevant folders/files for scanning based on patterns and configuration - **Exclusions Management:** Applies exclusion rules based on configuration and DevSecOps policy @@ -363,13 +415,14 @@ Defines exclusion rules for repositories and specific security checks. - `runner_iac_scan.py`: Main entry point for IaC scan orchestration - `entry_point_tool.py`: Initializes the IaC engine and triggers the scan process - `iac_scan.py`: Core use case for executing the scan, handling configuration, exclusions, and result aggregation -- **Adapters:** Integrations for IaC security tools (Checkov, KICS, Kubescape) +- **Adapters:** Integrations for IaC security tools (Checkov, KICS, Kubescape, Conftest) ## Supported Tools and Features - **Checkov:** Scans Terraform, CloudFormation, Kubernetes, Docker, and more for security misconfigurations - **KICS:** Scans IaC files for vulnerabilities and compliance issues with OpenAPI support - **Kubescape:** Focused on Kubernetes security scanning with RBAC analysis +- **Conftest:** Policy-as-code scanning using OPA/Rego policies; supports namespace-based platform filtering and GitHub-hosted policy repositories - **Configurable Exclusions:** Supports exclusion of files/folders and custom ignore patterns with expiration dates - **Thresholds and Policies:** Handles custom thresholds and build-breaking policies for vulnerabilities and compliance - **Multi-platform Support:** Cross-platform binary distribution for Linux, macOS, and Windows diff --git a/example_remote_config_local/engine_core/ConfigTool.json b/example_remote_config_local/engine_core/ConfigTool.json index 21bbaab6b..2d395833c 100644 --- a/example_remote_config_local/engine_core/ConfigTool.json +++ b/example_remote_config_local/engine_core/ConfigTool.json @@ -189,7 +189,7 @@ }, "ENGINE_IAC": { "ENABLED": true, - "TOOL": "CHECKOV|KUBESCAPE|KICS", + "TOOL": "CHECKOV|KUBESCAPE|KICS|CONFTEST", "PRIORITY": "STANDARD|DISCREET" }, "ENGINE_CONTAINER": { diff --git a/example_remote_config_local/engine_sast/engine_iac/ConfigTool.json b/example_remote_config_local/engine_sast/engine_iac/ConfigTool.json index 01148a845..f8ae8624a 100755 --- a/example_remote_config_local/engine_sast/engine_iac/ConfigTool.json +++ b/example_remote_config_local/engine_sast/engine_iac/ConfigTool.json @@ -809,5 +809,46 @@ } } } + }, + "CONFTEST": { + "VERSION": "0.56.0", + "POLICY_PATH": "policy", + "USE_EXTERNAL_CHECKS_DIR": false, + "EXTERNAL_DIR_OWNER": "", + "EXTERNAL_DIR_REPOSITORY": "", + "EXTERNAL_DIR_POLICIES_PATH": "policy", + "APP_ID_GITHUB": "", + "INSTALLATION_ID_GITHUB": "", + "DEFAULT_SEVERITY": "high", + "DEFAULT_CATEGORY": "vulnerability", + "RULES": { + "RULES_N8N": { + "CONF_N8N_BC_1": { + "checkID": "CONF_N8N_BC_1 Webhook node exposes an unauthenticated endpoint", + "severity": "Critical", + "category": "Vulnerability" + }, + "CONF_N8N_BC_2": { + "checkID": "CONF_N8N_BC_2 HTTP Request node uses insecure HTTP", + "severity": "Medium", + "category": "Vulnerability" + }, + "CONF_N8N_BC_3": { + "checkID": "CONF_N8N_BC_3 Execute Command node can introduce command-injection risks", + "severity": "High", + "category": "Vulnerability" + }, + "CONF_N8N_BC_4": { + "checkID": "CONF_N8N_BC_4 Active workflow has no error-handling node", + "severity": "Low", + "category": "Compliance" + }, + "CONF_N8N_BC_5": { + "checkID": "CONF_N8N_BC_5 Credential hardcoded in node parameter", + "severity": "Critical", + "category": "Vulnerability" + } + } + } } } diff --git a/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py b/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py index 64fa38c26..d8e3f3132 100755 --- a/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py +++ b/tools/devsecops_engine_tools/engine_core/src/applications/runner_engine_core.py @@ -109,6 +109,7 @@ def get_inputs_from_cli(args): "checkov", "kics", "kubescape", + "conftest", "trufflehog", "gitleaks", "prisma", @@ -156,7 +157,7 @@ def get_inputs_from_cli(args): parser.add_argument( "-p", "--platform", - type=parse_choices({"all", "docker", "k8s", "cloudformation", "openapi", "terraform", "serverless", "bicep"}), + type=parse_choices({"all", "docker", "k8s", "cloudformation", "openapi", "terraform", "serverless", "bicep", "n8n"}), required=False, default="all", help="Platform to scan, applies only to the engine_iac tool and it is possible to select several {all, docker, k8s, cloudformation, openapi, terraform}", @@ -262,7 +263,7 @@ def get_inputs_from_cli(args): ) TOOLS = { - "engine_iac": ["checkov", "kics", "kubescape"], + "engine_iac": ["checkov", "kics", "kubescape", "conftest"], "engine_secret": ["trufflehog", "gitleaks", "all_tools"], "engine_container": ["prisma", "trivy"], "engine_dependencies": ["xray", "dependency_check", "trivy"], diff --git a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py index fa4f1ac86..01f852163 100755 --- a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py +++ b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/defect_dojo/defect_dojo.py @@ -58,6 +58,7 @@ class DefectDojoPlatform(VulnerabilityManagementGateway): scan_type_mapping = { "CHECKOV": "Checkov Scan", + "CONFTEST": "Conftest Scan", "PRISMA": "Twistlock Image Scan", "XRAY": "JFrog Xray On Demand Binary Scan", "TRUFFLEHOG": "Trufflehog Scan", diff --git a/tools/devsecops_engine_tools/engine_sast/engine_iac/src/applications/runner_iac_scan.py b/tools/devsecops_engine_tools/engine_sast/engine_iac/src/applications/runner_iac_scan.py index 26ce66e4d..a7291a742 100644 --- a/tools/devsecops_engine_tools/engine_sast/engine_iac/src/applications/runner_iac_scan.py +++ b/tools/devsecops_engine_tools/engine_sast/engine_iac/src/applications/runner_iac_scan.py @@ -10,6 +10,9 @@ from devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.kics.kics_tool import ( KicsTool ) +from devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool import ( + ConftestTool +) def runner_engine_iac(dict_args, tool, secret_tool, devops_platform_gateway, remote_config_source_gateway, env): @@ -20,7 +23,8 @@ def runner_engine_iac(dict_args, tool, secret_tool, devops_platform_gateway, rem tools = { "CHECKOV": CheckovTool(), "KUBESCAPE": KubescapeTool(), - "KICS": KicsTool() + "KICS": KicsTool(), + "CONFTEST": ConftestTool(), } if tool in tools: diff --git a/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/__init__.py b/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/conftest_deserealizator.py b/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/conftest_deserealizator.py new file mode 100644 index 000000000..7ad5ea7cf --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/conftest_deserealizator.py @@ -0,0 +1,54 @@ +from devsecops_engine_tools.engine_core.src.domain.model.finding import ( + Category, + Finding, +) +from datetime import datetime +from dataclasses import dataclass + + +@dataclass +class ConftestDeserealizator: + @classmethod + def get_list_finding( + cls, results_scan_list: list, default_severity: str, default_category: str, rules_config: dict = None + ) -> "list[Finding]": + rules_lookup = {} + for rule_group in (rules_config or {}).values(): + for rule_id, rule_data in rule_group.items(): + rules_lookup[rule_id] = rule_data + + list_open_findings = [] + + for file_result in results_scan_list: + filename = file_result.get("filename", "unknown") + + for failure in file_result.get("failures", []) or []: + msg = failure.get("msg", "unknown") + metadata = failure.get("metadata", {}) or {} + query = metadata.get("query", "unknown") + node_type = metadata.get("node_type", "unknown") + node_id = metadata.get("node_id", "unknown") + where = f"{filename}: {node_type}.{node_id}" + + rule_id = metadata.get("id", "") + rule_meta = rules_lookup.get(rule_id, {}) + + severity = rule_meta.get("severity", default_severity).lower() + category_str = rule_meta.get("category", default_category).lower() + + finding_open = Finding( + id=rule_id or query, + cvss=None, + where=where, + description=msg, + severity=severity, + identification_date=datetime.now().strftime("%d%m%Y"), + published_date_cve=None, + module="engine_iac", + category=Category(category_str), + requirements=rule_meta.get("url"), + tool="Conftest", + ) + list_open_findings.append(finding_open) + + return list_open_findings diff --git a/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/conftest_tool.py b/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/conftest_tool.py new file mode 100644 index 000000000..17043842e --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sast/engine_iac/src/infrastructure/driven_adapters/conftest/conftest_tool.py @@ -0,0 +1,219 @@ +import subprocess +import json +import platform +import requests +import os +import shutil +from typing import List + +from devsecops_engine_tools.engine_sast.engine_iac.src.domain.model.context_iac import ( + ContextIac, +) +from devsecops_engine_tools.engine_sast.engine_iac.src.domain.model.gateways.tool_gateway import ( + ToolGateway, +) +from devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_deserealizator import ( + ConftestDeserealizator, +) +from devsecops_engine_tools.engine_utilities.utils.utils import Utils +from devsecops_engine_tools.engine_utilities.utils.logger_info import MyLogger +from devsecops_engine_tools.engine_utilities import settings + +logger = MyLogger.__call__(**settings.SETTING_LOGGER).get_logger() + +PLATFORM_BINARY_MAP = { + "Linux": ("conftest_{version}_Linux_x86_64.tar.gz", "conftest"), + "Darwin": ("conftest_{version}_Darwin_x86_64.tar.gz", "conftest"), + "Windows": ("conftest_{version}_Windows_x86_64.zip", "conftest.exe"), +} + + +class ConftestTool(ToolGateway): + TOOL_CONFTEST = "CONFTEST" + RESULTS_FILE = "results_conftest.json" + + def run_tool(self, config_tool, folders_to_scan, **kwargs): + conftest_config = config_tool.get(self.TOOL_CONFTEST, {}) + version = conftest_config.get("VERSION") + default_severity = conftest_config.get("DEFAULT_SEVERITY", "low") + default_category = conftest_config.get("DEFAULT_CATEGORY", "vulnerability") + + secret_tool = kwargs.get("secret_tool") + secret_external_checks = kwargs.get("secret_external_checks") + work_folder = kwargs.get("work_folder") + platform_to_scan = kwargs.get("platform_to_scan") or None + + policy_path = self._resolve_policy_path( + conftest_config, secret_tool, secret_external_checks, work_folder + ) + + os_platform = platform.system() + command_prefix = self._install_binary(version, os_platform) + + if command_prefix is None: + return [], None + + results = self._execute_conftest(folders_to_scan, command_prefix, policy_path, namespaces=platform_to_scan) + + if results is None: + return [], None + + results_path = os.path.abspath(self.RESULTS_FILE) + with open(results_path, "w") as f: + json.dump(results, f, indent=4) + + deserealizator = ConftestDeserealizator() + findings_list = deserealizator.get_list_finding( + results, default_severity, default_category, + rules_config=conftest_config.get("RULES", {}) + ) + + return findings_list, results_path + + def _resolve_policy_path( + self, conftest_config, secret_tool, secret_external_checks, work_folder + ): + if conftest_config.get("USE_EXTERNAL_CHECKS_DIR", False): + util = Utils() + util.configurate_external_checks( + self.TOOL_CONFTEST, + {self.TOOL_CONFTEST: conftest_config}, + secret_tool, + secret_external_checks, + agent_work_folder=work_folder, + ) + base_policies_path = os.path.join(work_folder, "rules", "conftest") + policies_subfolder = conftest_config.get( + "EXTERNAL_DIR_POLICIES_PATH", "" + ).strip("/\\") + + if policies_subfolder: + candidate_path = os.path.join(base_policies_path, policies_subfolder) + if os.path.isdir(candidate_path): + return candidate_path + + return base_policies_path + + return conftest_config.get("POLICY_PATH", "policy") + + def get_iac_context_from_results(self, path_file_results: str) -> List[ContextIac]: + with open(path_file_results, "r") as f: + results = json.load(f) + + context_list = [] + for file_result in results: + filename = file_result.get("filename", "unknown") + for failure in file_result.get("failures", []) or []: + msg = failure.get("msg", "unknown") + metadata = failure.get("metadata", {}) or {} + finding_id = metadata.get("id") or metadata.get("query", "unknown") + severity = metadata.get("severity", "medium").lower() + + context_iac = ContextIac( + id=finding_id, + check_name=msg, + check_class="conftest", + severity=severity, + where=filename, + resource=filename, + description=msg, + module="engine_iac", + tool="Conftest", + ) + context_list.append(context_iac) + + return context_list + + def _install_binary(self, version: str, os_platform: str) -> str: + if os_platform not in PLATFORM_BINARY_MAP: + logger.warning(f"Unsupported platform for conftest: {os_platform}") + return None + + filename_template, binary_name = PLATFORM_BINARY_MAP[os_platform] + filename = filename_template.format(version=version) + + if os.path.isfile(binary_name): + logger.info(f"Conftest binary already present: {binary_name}") + return f"./{binary_name}" + + binary_in_path = shutil.which(binary_name) or shutil.which("conftest") + if binary_in_path: + logger.info(f"Conftest binary found in PATH: {binary_in_path}") + return binary_in_path + + base_url = ( + f"https://github.com/open-policy-agent/conftest/releases/download/v{version}/" + ) + url = base_url + filename + + try: + logger.info(f"Downloading conftest from {url}") + response = requests.get(url, allow_redirects=True, timeout=60) + response.raise_for_status() + + with open(filename, "wb") as f: + f.write(response.content) + + if filename.endswith(".tar.gz"): + Utils().extract_targz_file(filename, ".") + elif filename.endswith(".zip"): + Utils().unzip_file(filename, ".") + + if os_platform != "Windows": + subprocess.run(["chmod", "+x", binary_name], check=True) + + return f"./{binary_name}" + + except Exception as e: + logger.error(f"Error installing conftest: {e}") + return None + + def _execute_conftest( + self, folders_to_scan: list, command_prefix: str, policy_path: str, namespaces=None + ): + files_to_test = self._collect_files(folders_to_scan) + + if not files_to_test: + logger.warning("No files found to scan with conftest") + return [] + + if namespaces: + namespace_args = [] + for ns in namespaces: + namespace_args += ["--namespace", ns] + else: + namespace_args = ["--all-namespaces"] + + command = ( + [command_prefix, "test"] + + files_to_test + + ["--policy", policy_path] + + namespace_args + + ["--output", "json", "--no-fail"] + ) + + try: + result = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + output = result.stdout.decode("utf-8").strip() + if not output: + logger.warning("Conftest returned empty output") + return None + return json.loads(output) + except Exception as e: + logger.error(f"Error executing conftest: {e}") + return None + + def _collect_files(self, folders_to_scan: list) -> list: + files = [] + for path in folders_to_scan: + if os.path.isfile(path): + files.append(path) + elif os.path.isdir(path): + for root, _, filenames in os.walk(path): + for fname in filenames: + files.append(os.path.join(root, fname)) + return files diff --git a/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/__init__.py b/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/test_conftest_deserealizator.py b/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/test_conftest_deserealizator.py new file mode 100644 index 000000000..025b26f3b --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/test_conftest_deserealizator.py @@ -0,0 +1,145 @@ +import unittest +from datetime import datetime +from devsecops_engine_tools.engine_core.src.domain.model.finding import Category, Finding +from devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_deserealizator import ( + ConftestDeserealizator, +) + + +class TestConftestDeserealizator(unittest.TestCase): + def setUp(self): + self.deserealizator = ConftestDeserealizator() + + def test_get_list_finding_empty(self): + result = self.deserealizator.get_list_finding([], "high", "vulnerability") + self.assertEqual(result, []) + + def test_get_list_finding_no_failures(self): + results = [{"filename": "deploy.yaml", "failures": [], "warnings": []}] + result = self.deserealizator.get_list_finding(results, "high", "vulnerability") + self.assertEqual(result, []) + + def test_get_list_finding_single_failure(self): + results = [ + { + "filename": "deploy.yaml", + "failures": [ + { + "msg": "Containers must not run as root", + "metadata": {"query": "data.main.deny"}, + } + ], + } + ] + findings = self.deserealizator.get_list_finding(results, "high", "vulnerability") + + self.assertEqual(len(findings), 1) + finding = findings[0] + self.assertEqual(finding.id, "data.main.deny") # no metadata.id → fallback to query + self.assertEqual(finding.description, "Containers must not run as root") + self.assertEqual(finding.where, "deploy.yaml: unknown.unknown") + self.assertEqual(finding.severity, "high") + self.assertEqual(finding.category, Category.VULNERABILITY) + self.assertEqual(finding.tool, "Conftest") + self.assertEqual(finding.module, "engine_iac") + + def test_get_list_finding_where_uses_node_type_and_node_id(self): + results = [ + { + "filename": "workflow.json", + "failures": [ + { + "msg": "Insecure node", + "metadata": { + "query": "data.n8n.deny", + "id": "CONF_N8N_BC_3", + "node_type": "n8n-nodes-base.executeCommand", + "node_id": "8a77aa44-0f57-4f6b-985a-4b5989138621", + }, + } + ], + } + ] + + findings = self.deserealizator.get_list_finding(results, "high", "vulnerability") + self.assertEqual(findings[0].where, "workflow.json: n8n-nodes-base.executeCommand.8a77aa44-0f57-4f6b-985a-4b5989138621") + + def test_get_list_finding_uses_rules_config_severity(self): + results = [ + { + "filename": "main.tf", + "failures": [ + { + "msg": "S3 bucket not encrypted", + "metadata": {"query": "data.n8n.deny", "id": "CONF_N8N_BC_1"}, + } + ], + } + ] + rules_config = { + "RULES_N8N": { + "CONF_N8N_BC_1": {"severity": "Critical", "category": "Vulnerability"} + } + } + findings = self.deserealizator.get_list_finding( + results, "low", "compliance", rules_config=rules_config + ) + + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].id, "CONF_N8N_BC_1") + self.assertEqual(findings[0].severity, "critical") + self.assertEqual(findings[0].category, Category.VULNERABILITY) + + def test_get_list_finding_multiple_files(self): + results = [ + { + "filename": "file1.yaml", + "failures": [ + {"msg": "Error 1", "metadata": {"query": "data.main.deny"}} + ], + }, + { + "filename": "file2.tf", + "failures": [ + {"msg": "Error 2", "metadata": {"query": "data.tf.deny"}}, + {"msg": "Error 3", "metadata": {"query": "data.tf.deny"}}, + ], + }, + ] + findings = self.deserealizator.get_list_finding(results, "medium", "vulnerability") + self.assertEqual(len(findings), 3) + + def test_get_list_finding_failures_none_handled(self): + results = [{"filename": "deploy.yaml", "failures": None}] + findings = self.deserealizator.get_list_finding(results, "high", "vulnerability") + self.assertEqual(findings, []) + + def test_get_list_finding_rules_config_url_becomes_requirements(self): + results = [ + { + "filename": "deploy.yaml", + "failures": [ + { + "msg": "Missing label", + "metadata": {"query": "data.n8n.deny", "id": "CONF_N8N_BC_2"}, + } + ], + } + ] + rules_config = { + "RULES_N8N": { + "CONF_N8N_BC_2": { + "severity": "medium", + "category": "vulnerability", + "url": "https://example.com/policy", + } + } + } + findings = self.deserealizator.get_list_finding( + results, "high", "vulnerability", rules_config=rules_config + ) + self.assertEqual(findings[0].requirements, "https://example.com/policy") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/test_conftest_tool.py b/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/test_conftest_tool.py new file mode 100644 index 000000000..49e8f085b --- /dev/null +++ b/tools/devsecops_engine_tools/engine_sast/engine_iac/test/infrastructure/driven_adapters/conftest/test_conftest_tool.py @@ -0,0 +1,370 @@ +import json +import os +import platform +import unittest +from unittest.mock import MagicMock, mock_open, patch + +from devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool import ( + ConftestTool, +) + + +CONFIG_TOOL = { + "CONFTEST": { + "VERSION": "0.56.0", + "POLICY_PATH": "policy", + "USE_EXTERNAL_CHECKS_DIR": False, + "EXTERNAL_DIR_OWNER": "", + "EXTERNAL_DIR_REPOSITORY": "", + "EXTERNAL_DIR_POLICIES_PATH": "policy", + "APP_ID_GITHUB": "", + "INSTALLATION_ID_GITHUB": "", + "DEFAULT_SEVERITY": "high", + "DEFAULT_CATEGORY": "vulnerability", + } +} + +CONFIG_TOOL_EXTERNAL = { + "CONFTEST": { + "VERSION": "0.56.0", + "USE_EXTERNAL_CHECKS_DIR": True, + "EXTERNAL_DIR_OWNER": "my-org", + "EXTERNAL_DIR_REPOSITORY": "conftest-policies", + "EXTERNAL_DIR_POLICIES_PATH": "policy", + "APP_ID_GITHUB": "123", + "INSTALLATION_ID_GITHUB": "456", + "DEFAULT_SEVERITY": "high", + "DEFAULT_CATEGORY": "vulnerability", + } +} + +SAMPLE_RESULTS = [ + { + "filename": "deploy.yaml", + "namespace": "main", + "successes": 1, + "failures": [ + { + "msg": "Containers must not run as root", + "metadata": {"query": "data.main.deny", "id": "CONF_N8N_BC_3"}, + } + ], + "warnings": [], + } +] + + +class TestConftestTool(unittest.TestCase): + def setUp(self): + self.tool = ConftestTool() + + # ------------------------------------------------------------------ # + # _collect_files # + # ------------------------------------------------------------------ # + + @patch("os.path.isfile", return_value=True) + def test_collect_files_single_file(self, _): + result = self.tool._collect_files(["deploy.yaml"]) + self.assertEqual(result, ["deploy.yaml"]) + + @patch("os.walk", return_value=[("/folder", [], ["a.yaml", "b.tf"])]) + @patch("os.path.isfile", return_value=False) + @patch("os.path.isdir", return_value=True) + def test_collect_files_directory(self, _isdir, _isfile, _walk): + result = self.tool._collect_files(["/folder"]) + self.assertEqual(sorted(result), sorted(["/folder/a.yaml", "/folder/b.tf"])) + + # ------------------------------------------------------------------ # + # _resolve_policy_path # + # ------------------------------------------------------------------ # + + def test_resolve_policy_path_local(self): + cfg = {"USE_EXTERNAL_CHECKS_DIR": False, "POLICY_PATH": "my_policies"} + result = self.tool._resolve_policy_path(cfg, None, None, "/tmp") + self.assertEqual(result, "my_policies") + + def test_resolve_policy_path_local_default(self): + cfg = {"USE_EXTERNAL_CHECKS_DIR": False} + result = self.tool._resolve_policy_path(cfg, None, None, "/tmp") + self.assertEqual(result, "policy") + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.Utils" + ) + def test_resolve_policy_path_external_dir(self, mock_utils_cls): + mock_utils = MagicMock() + mock_utils_cls.return_value = mock_utils + + cfg = { + "USE_EXTERNAL_CHECKS_DIR": True, + "EXTERNAL_DIR_OWNER": "org", + "EXTERNAL_DIR_REPOSITORY": "repo", + "EXTERNAL_DIR_POLICIES_PATH": "policy", + "APP_ID_GITHUB": "1", + "INSTALLATION_ID_GITHUB": "2", + } + result = self.tool._resolve_policy_path(cfg, None, "token:abc", "/tmp") + + mock_utils.configurate_external_checks.assert_called_once_with( + "CONFTEST", + {"CONFTEST": cfg}, + None, + "token:abc", + agent_work_folder="/tmp", + ) + self.assertEqual(result, os.path.join("/tmp", "rules", "conftest")) + + @patch("os.path.isdir", return_value=True) + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.Utils" + ) + def test_resolve_policy_path_external_custom_subfolder(self, mock_utils_cls, _isdir): + mock_utils_cls.return_value = MagicMock() + cfg = { + "USE_EXTERNAL_CHECKS_DIR": True, + "EXTERNAL_DIR_POLICIES_PATH": "rego_rules", + } + result = self.tool._resolve_policy_path(cfg, None, None, "/workspace") + self.assertEqual( + result, + os.path.join("/workspace", "rules", "conftest", "rego_rules"), + ) + + # ------------------------------------------------------------------ # + # _install_binary # + # ------------------------------------------------------------------ # + + @patch("os.path.isfile", return_value=True) + def test_install_binary_already_present_linux(self, _): + result = self.tool._install_binary("0.56.0", "Linux") + self.assertEqual(result, "./conftest") + + @patch("os.path.isfile", return_value=True) + def test_install_binary_already_present_windows(self, _): + result = self.tool._install_binary("0.56.0", "Windows") + self.assertEqual(result, "./conftest.exe") + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.requests.get" + ) + @patch("shutil.which", return_value="/usr/local/bin/conftest") + @patch("os.path.isfile", return_value=False) + def test_install_binary_uses_path_before_download( + self, _isfile, _which, mock_get + ): + result = self.tool._install_binary("0.56.0", "Linux") + self.assertEqual(result, "/usr/local/bin/conftest") + mock_get.assert_not_called() + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.logger" + ) + def test_install_binary_unsupported_platform(self, mock_logger): + result = self.tool._install_binary("0.56.0", "SunOS") + self.assertIsNone(result) + mock_logger.warning.assert_called_once() + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.Utils" + ) + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + @patch("builtins.open", new_callable=mock_open) + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.requests.get" + ) + @patch("os.path.isfile", return_value=False) + def test_install_binary_downloads_and_extracts_linux( + self, _isfile, mock_get, mock_file, mock_subprocess, _which, mock_utils_cls + ): + mock_get.return_value = MagicMock(status_code=200, content=b"binary") + mock_get.return_value.raise_for_status = MagicMock() + mock_subprocess.return_value = MagicMock(returncode=0) + mock_utils = MagicMock() + mock_utils_cls.return_value = mock_utils + + result = self.tool._install_binary("0.56.0", "Linux") + self.assertEqual(result, "./conftest") + mock_utils.extract_targz_file.assert_called_once_with( + "conftest_0.56.0_Linux_x86_64.tar.gz", "." + ) + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.requests.get", + side_effect=Exception("network error"), + ) + @patch("shutil.which", return_value=None) + @patch("os.path.isfile", return_value=False) + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.logger" + ) + def test_install_binary_download_failure(self, mock_logger, _isfile, _which, _get): + result = self.tool._install_binary("0.56.0", "Linux") + self.assertIsNone(result) + mock_logger.error.assert_called_once() + + # ------------------------------------------------------------------ # + # _execute_conftest # + # ------------------------------------------------------------------ # + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.logger" + ) + def test_execute_conftest_no_files(self, mock_logger): + with patch.object(self.tool, "_collect_files", return_value=[]): + result = self.tool._execute_conftest([], "./conftest", "policy") + self.assertEqual(result, []) + mock_logger.warning.assert_called_once() + + @patch("subprocess.run") + def test_execute_conftest_success(self, mock_subprocess): + mock_subprocess.return_value = MagicMock( + stdout=json.dumps(SAMPLE_RESULTS).encode("utf-8") + ) + with patch.object(self.tool, "_collect_files", return_value=["deploy.yaml"]): + result = self.tool._execute_conftest(["deploy.yaml"], "./conftest", "policy") + self.assertEqual(result, SAMPLE_RESULTS) + + @patch("subprocess.run") + def test_execute_conftest_single_policy_arg(self, mock_subprocess): + """Verifies a single --policy flag is built from the policy_path string.""" + mock_subprocess.return_value = MagicMock( + stdout=json.dumps(SAMPLE_RESULTS).encode("utf-8") + ) + with patch.object(self.tool, "_collect_files", return_value=["deploy.yaml"]): + self.tool._execute_conftest( + ["deploy.yaml"], "./conftest", "policy" + ) + cmd = mock_subprocess.call_args[0][0] + self.assertEqual(cmd.count("--policy"), 1) + self.assertIn("policy", cmd) + self.assertIn("--all-namespaces", cmd) + + @patch("subprocess.run") + def test_execute_conftest_empty_output(self, mock_subprocess): + mock_subprocess.return_value = MagicMock(stdout=b"") + with patch.object(self.tool, "_collect_files", return_value=["deploy.yaml"]): + result = self.tool._execute_conftest(["deploy.yaml"], "./conftest", "policy") + self.assertIsNone(result) + + @patch("subprocess.run", side_effect=Exception("subprocess failed")) + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.logger" + ) + def test_execute_conftest_exception(self, mock_logger, _): + with patch.object(self.tool, "_collect_files", return_value=["deploy.yaml"]): + result = self.tool._execute_conftest(["deploy.yaml"], "./conftest", "policy") + self.assertIsNone(result) + mock_logger.error.assert_called_once() + + @patch("subprocess.run") + def test_execute_conftest_uses_platform_namespace(self, mock_subprocess): + """Verifies --namespace flag is used per platform instead of --all-namespaces.""" + mock_subprocess.return_value = MagicMock( + stdout=json.dumps(SAMPLE_RESULTS).encode("utf-8") + ) + with patch.object(self.tool, "_collect_files", return_value=["workflow.json"]): + self.tool._execute_conftest( + ["workflow.json"], "./conftest", "policy", namespaces=["n8n"] + ) + cmd = mock_subprocess.call_args[0][0] + self.assertIn("--namespace", cmd) + self.assertIn("n8n", cmd) + self.assertNotIn("--all-namespaces", cmd) + + # ------------------------------------------------------------------ # + # run_tool # + # ------------------------------------------------------------------ # + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.logger" + ) + def test_run_tool_binary_install_fails(self, _): + with patch.object(self.tool, "_install_binary", return_value=None): + findings, path = self.tool.run_tool(CONFIG_TOOL, ["folder"]) + self.assertEqual(findings, []) + self.assertIsNone(path) + + @patch("builtins.open", new_callable=mock_open) + @patch("json.dump") + @patch("os.path.abspath", return_value="/tmp/results_conftest.json") + def test_run_tool_no_results(self, _abspath, _dump, _open): + with patch.object(self.tool, "_install_binary", return_value="./conftest"): + with patch.object(self.tool, "_execute_conftest", return_value=[]): + findings, path = self.tool.run_tool(CONFIG_TOOL, ["folder"]) + self.assertEqual(findings, []) + self.assertEqual(path, "/tmp/results_conftest.json") + + @patch("builtins.open", new_callable=mock_open) + @patch("json.dump") + @patch("os.path.abspath", return_value="/tmp/results_conftest.json") + def test_run_tool_returns_findings_and_path(self, _abspath, _dump, _open): + with patch.object(self.tool, "_install_binary", return_value="./conftest"): + with patch.object( + self.tool, "_execute_conftest", return_value=SAMPLE_RESULTS + ): + findings, path = self.tool.run_tool(CONFIG_TOOL, ["folder"]) + + self.assertEqual(len(findings), 1) + self.assertEqual(path, "/tmp/results_conftest.json") + + @patch( + "devsecops_engine_tools.engine_sast.engine_iac.src.infrastructure.driven_adapters.conftest.conftest_tool.Utils" + ) + @patch("builtins.open", new_callable=mock_open) + @patch("json.dump") + @patch("os.path.abspath", return_value="/tmp/results_conftest.json") + def test_run_tool_uses_external_dir_policies( + self, _abspath, _dump, _open, mock_utils_cls + ): + mock_utils = MagicMock() + mock_utils_cls.return_value = mock_utils + + with patch.object(self.tool, "_install_binary", return_value="./conftest"): + with patch.object( + self.tool, "_execute_conftest", return_value=SAMPLE_RESULTS + ) as mock_exec: + findings, path = self.tool.run_tool( + CONFIG_TOOL_EXTERNAL, + ["folder"], + secret_tool=None, + secret_external_checks="github_token:abc123", + work_folder="/tmp", + ) + + mock_utils.configurate_external_checks.assert_called_once() + # _execute_conftest receives a string path and no namespaces + expected_policy_path = os.path.join("/tmp", "rules", "conftest") + mock_exec.assert_called_once_with(["folder"], "./conftest", expected_policy_path, namespaces=None) + self.assertEqual(len(findings), 1) + + # ------------------------------------------------------------------ # + # get_iac_context_from_results # + # ------------------------------------------------------------------ # + + @patch( + "builtins.open", + new_callable=mock_open, + read_data=json.dumps(SAMPLE_RESULTS), + ) + def test_get_iac_context_from_results(self, _): + context_list = self.tool.get_iac_context_from_results("results_conftest.json") + self.assertEqual(len(context_list), 1) + ctx = context_list[0] + self.assertEqual(ctx.id, "CONF_N8N_BC_3") + self.assertEqual(ctx.tool, "Conftest") + self.assertEqual(ctx.where, "deploy.yaml") + self.assertEqual(ctx.check_name, "Containers must not run as root") + + @patch( + "builtins.open", + new_callable=mock_open, + read_data=json.dumps([{"filename": "f.yaml", "failures": None}]), + ) + def test_get_iac_context_from_results_null_failures(self, _): + context_list = self.tool.get_iac_context_from_results("results_conftest.json") + self.assertEqual(context_list, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/import_scan.py b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/import_scan.py index 8440fdde7..3a24def86 100755 --- a/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/import_scan.py +++ b/tools/devsecops_engine_tools/engine_utilities/defect_dojo/domain/serializers/import_scan.py @@ -36,6 +36,7 @@ "Checkmarx Scan", "Checkmarx Scan detailed", "Checkov Scan", + "Conftest Scan", "Clair Klar Scan", "Clair Scan", "Cloudsploit Scan", diff --git a/tools/devsecops_engine_tools/engine_utilities/utils/utils.py b/tools/devsecops_engine_tools/engine_utilities/utils/utils.py index b0fe60d55..63a9a460c 100755 --- a/tools/devsecops_engine_tools/engine_utilities/utils/utils.py +++ b/tools/devsecops_engine_tools/engine_utilities/utils/utils.py @@ -86,7 +86,7 @@ def configurate_external_checks(self, tool, config_tool, secret_tool, secret_ext logger.warning("The secret is not configured for external controls") - elif config_tool[tool]["USE_EXTERNAL_CHECKS_GIT"] and platform.system() in ( + elif config_tool[tool].get("USE_EXTERNAL_CHECKS_GIT", False) and platform.system() in ( "Linux", "Darwin", ): config_knowns_hosts( @@ -99,7 +99,7 @@ def configurate_external_checks(self, tool, config_tool, secret_tool, secret_ext ssh_key_password = decode_base64(secret["repository_ssh_password"]) agent_env = add_ssh_private_key(ssh_key_file_path, ssh_key_password) - elif config_tool[tool]["USE_EXTERNAL_CHECKS_DIR"]: + elif config_tool[tool].get("USE_EXTERNAL_CHECKS_DIR", False): if not github_token: github_token = github_api.get_installation_access_token( secret.get("github_apps"),