diff --git a/docs/Docusaurus/docs/modules/engine_core.md b/docs/Docusaurus/docs/modules/engine_core.md index 914384604..4afc9ed65 100644 --- a/docs/Docusaurus/docs/modules/engine_core.md +++ b/docs/Docusaurus/docs/modules/engine_core.md @@ -145,6 +145,8 @@ Configuration of the driven adapters in the main layer and management of on/off "LIFECYCLE_PIPELINES": { "pipeline_name1": "pre-build" }, + "BREAK_ON_BUILD_FAILURE": true, + "BUILD_FAILURE_PATTERNS": ["BUILD FAILED", "BUILD FAILURE", "npm ERR!"], "OVERRIDE_REGISTRIES": false, "REGISTRIES": { "MAVEN_CENTRAL_URL": "", @@ -368,6 +370,8 @@ Configuration of the driven adapters in the main layer and management of on/off - **CDXGEN** - **FETCH_LICENSE**: `true` or `false`. When enabled, cdxgen fetches license information for each component from public registries and includes it in the generated SBOM. Recommended when `--use_license_analyzer true` is used. - **INSTALL_DEPENDENCIES**: `true` or `false`. When enabled, cdxgen installs project dependencies before generating the SBOM, improving component coverage. + - **BREAK_ON_BUILD_FAILURE**: `true` or `false` (default `true`). cdxgen can exit with return code `0` even when the underlying build tool actually failed, silently producing an incomplete/empty SBOM. When enabled, the stdout/stderr of the cdxgen process is checked against `BUILD_FAILURE_PATTERNS` and, if any pattern matches, the SBOM generation is aborted and the error is logged so the affected pipeline is easy to spot. + - **BUILD_FAILURE_PATTERNS**: Array of regular expressions (case-insensitive) used to detect build failures in the cdxgen output. There are no built-in defaults; patterns must be defined entirely via remote config for the languages/build tools relevant to your pipelines. Example: `["BUILD FAILED", "BUILD FAILURE", "npm ERR!"]`. - **OVERRIDE_REGISTRIES**: `true` or `false`. When enabled, the registry URLs defined in `REGISTRIES` are set as environment variables before cdxgen runs, redirecting dependency resolution to internal or private registries. - **REGISTRIES**: Map of environment variable names to registry URLs used when `OVERRIDE_REGISTRIES` is `true`. - **MAVEN_CENTRAL_URL**: Maven registry URL. diff --git a/example_remote_config_local/engine_core/ConfigTool.json b/example_remote_config_local/engine_core/ConfigTool.json index 2d395833c..68156fe29 100644 --- a/example_remote_config_local/engine_core/ConfigTool.json +++ b/example_remote_config_local/engine_core/ConfigTool.json @@ -124,6 +124,8 @@ "LIFECYCLE_PIPELINES": { "pipeline_name1": "pre-build" }, + "BREAK_ON_BUILD_FAILURE": true, + "BUILD_FAILURE_PATTERNS": ["BUILD FAILED", "BUILD FAILURE", "npm ERR!"], "OVERRIDE_REGISTRIES": false, "REGISTRIES": { "MAVEN_CENTRAL_URL": "https://repo1.maven.org/maven2/", diff --git a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/cdxgen/cdxgen.py b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/cdxgen/cdxgen.py index 395015ec0..33c70b94c 100644 --- a/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/cdxgen/cdxgen.py +++ b/tools/devsecops_engine_tools/engine_core/src/infrastructure/driven_adapters/cdxgen/cdxgen.py @@ -3,6 +3,7 @@ import subprocess import platform import os +import re from devsecops_engine_tools.engine_core.src.domain.model.gateway.sbom_manager import ( SbomManagerGateway, @@ -35,6 +36,9 @@ def get_components(self, artifact, config, service_name) -> "list[Component]": debug_pipelines = config["CDXGEN"].get("DEBUG_PIPELINES", []) lifecycle_pipelines = config["CDXGEN"].get("LIFECYCLE_PIPELINES", {}) spec_version = config["CDXGEN"].get("SPEC_VERSION", "1.6") + break_on_build_failure = config["CDXGEN"].get("BREAK_ON_BUILD_FAILURE", True) + build_failure_patterns = config["CDXGEN"].get("BUILD_FAILURE_PATTERNS", []) + failure_patterns = build_failure_patterns if break_on_build_failure else [] if config["CDXGEN"].get("OVERRIDE_REGISTRIES", False): registries = config["CDXGEN"].get("REGISTRIES", {}) @@ -86,13 +90,14 @@ def get_components(self, artifact, config, service_name) -> "list[Component]": logger.warning(f"{os_platform} is not supported.") return None - result_sbom = self._run_cdxgen(command_prefix, artifact, service_name, exclude_types, exclude_paths, recurse, install_deps, lifecycle_pipelines, enable_debug, spec_version) + result_sbom = self._run_cdxgen(command_prefix, artifact, service_name, exclude_types, exclude_paths, recurse, install_deps, lifecycle_pipelines, enable_debug, spec_version, failure_patterns) return get_list_component(result_sbom, config["CDXGEN"]["OUTPUT_FORMAT"]) except Exception as e: logger.error(f"Error generating SBOM: {e}") return None - def _run_cdxgen(self, command_prefix, artifact, service_name, exclude_types, exclude_paths, recurse, install_deps, lifecycle_pipelines, enable_debug=False, spec_version="1.6"): + def _run_cdxgen(self, command_prefix, artifact, service_name, exclude_types, exclude_paths, recurse, install_deps, lifecycle_pipelines, enable_debug=False, spec_version="1.6", failure_patterns=None): + failure_patterns = failure_patterns or [] result_file = f"{service_name}_SBOM.json" command = [ command_prefix, @@ -143,7 +148,18 @@ def _run_cdxgen(self, command_prefix, artifact, service_name, exclude_types, exc logger.info(f"CDXGEN stdout: {result.stdout}") if result.stderr: logger.info(f"CDXGEN stderr: {result.stderr}") - + + matched_pattern = self._detect_build_failure( + f"{result.stdout or ''}\n{result.stderr or ''}", failure_patterns + ) + if matched_pattern: + self._remove_incomplete_sbom(result_file) + raise Exception( + f"Detected build failure pattern '{matched_pattern}' in cdxgen output for " + f"'{service_name}'. The underlying build likely failed; aborting to avoid " + "generating an incomplete or empty SBOM." + ) + if result.returncode == 0: print(f"SBOM generated and saved to: {result_file}") return result_file @@ -153,6 +169,27 @@ def _run_cdxgen(self, command_prefix, artifact, service_name, exclude_types, exc except Exception as e: logger.error(f"Error running cdxgen: {e}") + def _detect_build_failure(self, output, patterns): + """Return the first configured regex pattern that matches the cdxgen output, if any.""" + if not output or not patterns: + return None + for pattern in patterns: + try: + if re.search(pattern, output, re.IGNORECASE): + return pattern + except re.error as e: + logger.debug(f"Invalid build failure regex pattern '{pattern}': {e}") + return None + + def _remove_incomplete_sbom(self, result_file): + """Best-effort removal of a possibly incomplete/empty SBOM file left by a failed build.""" + try: + if result_file and os.path.exists(result_file): + os.remove(result_file) + logger.info(f"Removed incomplete SBOM file: {result_file}") + except OSError as e: + logger.debug(f"Could not remove incomplete SBOM file '{result_file}': {e}") + def _check_cdxgen_in_path(self): """Check if cdxgen is available in PATH and return its path if found.""" try: diff --git a/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/cdxgen/test_cdxgen.py b/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/cdxgen/test_cdxgen.py index 5e52f5933..bb4724085 100644 --- a/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/cdxgen/test_cdxgen.py +++ b/tools/devsecops_engine_tools/engine_core/test/infrastructure/driven_adapters/cdxgen/test_cdxgen.py @@ -66,7 +66,7 @@ def test_get_components_linux_success(self, mock_platform, mock_get_list_compone "https://github.com/CycloneDX/cdxgen/releases/download/v10.2.0/cdxgen-linux-amd64", "cdxgen" ) - mock_run.assert_called_once_with('./cdxgen-linux-amd64', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6') + mock_run.assert_called_once_with('./cdxgen-linux-amd64', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', []) mock_get_list_component.assert_called_once_with('test_service_SBOM.json', 'json') @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.platform.machine') @@ -92,7 +92,7 @@ def test_get_components_linux_success_arm64(self, mock_platform, mock_get_list_c "https://github.com/CycloneDX/cdxgen/releases/download/v10.2.0/cdxgen-linux-arm64", "cdxgen" ) - mock_run.assert_called_once_with('./cdxgen-linux-arm64', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6') + mock_run.assert_called_once_with('./cdxgen-linux-arm64', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', []) mock_get_list_component.assert_called_once_with('test_service_SBOM.json', 'json') @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.get_list_component') @@ -325,7 +325,7 @@ def test_get_components_debug_mode_service_in_list(self, mock_logger, mock_platf # Assert self.assertEqual(result, self.mock_components) mock_logger.info.assert_called_with(f"Enabling debug mode for pipeline: {self.service_name}") - mock_run.assert_called_once_with('./cdxgen-linux-amd64', self.artifact, self.service_name, [], [], True, False, {}, True, '1.6') + mock_run.assert_called_once_with('./cdxgen-linux-amd64', self.artifact, self.service_name, [], [], True, False, {}, True, '1.6', []) @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.get_list_component') @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.platform.system') @@ -356,7 +356,7 @@ def test_get_components_debug_mode_service_not_in_list(self, mock_platform, mock # Assert self.assertEqual(result, self.mock_components) - mock_run.assert_called_once_with('./cdxgen-linux-amd64', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6') + mock_run.assert_called_once_with('./cdxgen-linux-amd64', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', []) @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.get_list_component') @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.platform.system') @@ -389,7 +389,7 @@ def test_get_components_fetch_license_enabled_sets_env(self, mock_platform, mock self.assertEqual(result, self.mock_components) self.assertEqual(os.environ.get("FETCH_LICENSE"), "true") mock_install.assert_called_once() - mock_run.assert_called_once_with('/usr/local/bin/cdxgen', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6') + mock_run.assert_called_once_with('/usr/local/bin/cdxgen', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', []) @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.get_list_component') @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.platform.system') @@ -422,7 +422,7 @@ def test_get_components_fetch_license_disabled_does_not_set_env(self, mock_platf self.assertEqual(result, self.mock_components) self.assertIsNone(os.environ.get("FETCH_LICENSE")) mock_install.assert_called_once() - mock_run.assert_called_once_with('/usr/local/bin/cdxgen', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6') + mock_run.assert_called_once_with('/usr/local/bin/cdxgen', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', []) @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.subprocess.run') def test_run_cdxgen_with_exclude_types_list(self, mock_subprocess): @@ -611,7 +611,7 @@ def test_get_components_uses_cdxgen_from_path(self, mock_logger, mock_platform, # Assert self.assertEqual(result, self.mock_components) mock_logger.info.assert_called_with(f"Using cdxgen from PATH: {cdxgen_path}") - mock_run.assert_called_once_with(cdxgen_path, self.artifact, self.service_name, [], [], True, True, {}, False, '1.6') + mock_run.assert_called_once_with(cdxgen_path, self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', []) @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.subprocess.run') def test_install_tool_unix_success(self, mock_subprocess): @@ -837,3 +837,220 @@ def test_run_cdxgen_nonzero_returncode(self, mock_subprocess, mock_logger): mock_logger.error.assert_called_once_with( "Error running cdxgen: CDXGEN command failed with return code: 1" ) + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.remove') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.path.exists') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.logger') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.subprocess.run') + def test_run_cdxgen_gradle_build_failed_pattern_breaks_execution( + self, mock_subprocess, mock_logger, mock_exists, mock_remove + ): + """cdxgen can return 0 even when the underlying Gradle build failed.""" + command_prefix = "/usr/local/bin/cdxgen" + mock_result = Mock(returncode=0, stdout="Some output\nBUILD FAILED\nmore output", stderr="") + mock_subprocess.return_value = mock_result + mock_exists.return_value = True + + result = self.cdxgen._run_cdxgen( + command_prefix, self.artifact, self.service_name, + [], [], True, True, {}, False, "1.6", ["BUILD FAILED"] + ) + + self.assertIsNone(result) + expected_result_file = f"{self.service_name}_SBOM.json" + mock_remove.assert_called_once_with(expected_result_file) + mock_logger.error.assert_called_once_with( + "Error running cdxgen: Detected build failure pattern 'BUILD FAILED' in cdxgen output for " + f"'{self.service_name}'. The underlying build likely failed; aborting to avoid " + "generating an incomplete or empty SBOM." + ) + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.path.exists') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.logger') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.subprocess.run') + def test_run_cdxgen_maven_build_failure_pattern_case_insensitive(self, mock_subprocess, mock_logger, mock_exists): + """Pattern matching is case-insensitive (e.g. Maven's 'BUILD FAILURE').""" + command_prefix = "/usr/local/bin/cdxgen" + mock_result = Mock(returncode=0, stdout="", stderr="[INFO] build failure summary") + mock_subprocess.return_value = mock_result + mock_exists.return_value = False + + result = self.cdxgen._run_cdxgen( + command_prefix, self.artifact, self.service_name, + [], [], True, True, {}, False, "1.6", ["BUILD FAILURE"] + ) + + self.assertIsNone(result) + self.assertIn( + "Detected build failure pattern 'BUILD FAILURE'", + mock_logger.error.call_args[0][0] + ) + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.remove') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.path.exists') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.subprocess.run') + def test_run_cdxgen_regex_failure_pattern(self, mock_subprocess, mock_exists, mock_remove): + """Patterns supplied via config are treated as regular expressions.""" + command_prefix = "/usr/local/bin/cdxgen" + mock_result = Mock(returncode=0, stdout="npm ERR! code E404", stderr="") + mock_subprocess.return_value = mock_result + mock_exists.return_value = True + + result = self.cdxgen._run_cdxgen( + command_prefix, self.artifact, self.service_name, + [], [], True, True, {}, False, "1.6", [r"npm ERR!\s+code\s+E\d+"] + ) + + self.assertIsNone(result) + mock_remove.assert_called_once_with(f"{self.service_name}_SBOM.json") + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.logger') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.subprocess.run') + def test_run_cdxgen_invalid_regex_pattern_is_skipped(self, mock_subprocess, mock_logger): + """An invalid regex pattern is logged and skipped instead of raising.""" + command_prefix = "/usr/local/bin/cdxgen" + mock_result = Mock(returncode=0, stdout="all good", stderr="") + mock_subprocess.return_value = mock_result + expected_result_file = f"{self.service_name}_SBOM.json" + + with patch('builtins.print'): + result = self.cdxgen._run_cdxgen( + command_prefix, self.artifact, self.service_name, + [], [], True, True, {}, False, "1.6", ["("] + ) + + self.assertEqual(result, expected_result_file) + mock_logger.debug.assert_called_once() + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.subprocess.run') + def test_run_cdxgen_no_failure_patterns_configured(self, mock_subprocess): + """When no patterns are configured, output is not inspected and success proceeds.""" + command_prefix = "/usr/local/bin/cdxgen" + mock_result = Mock(returncode=0, stdout="BUILD FAILED", stderr="") + mock_subprocess.return_value = mock_result + expected_result_file = f"{self.service_name}_SBOM.json" + + with patch('builtins.print'): + result = self.cdxgen._run_cdxgen( + command_prefix, self.artifact, self.service_name, + [], [], True, True, {}, False, "1.6", [] + ) + + self.assertEqual(result, expected_result_file) + + def test_detect_build_failure_no_match(self): + result = self.cdxgen._detect_build_failure( + "everything compiled successfully", ["BUILD FAILED", "BUILD FAILURE"] + ) + self.assertIsNone(result) + + def test_detect_build_failure_empty_output(self): + result = self.cdxgen._detect_build_failure("", ["BUILD FAILED"]) + self.assertIsNone(result) + + def test_detect_build_failure_no_patterns(self): + result = self.cdxgen._detect_build_failure("BUILD FAILED", []) + self.assertIsNone(result) + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.remove') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.path.exists') + def test_remove_incomplete_sbom_removes_existing_file(self, mock_exists, mock_remove): + mock_exists.return_value = True + self.cdxgen._remove_incomplete_sbom("test_service_SBOM.json") + mock_remove.assert_called_once_with("test_service_SBOM.json") + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.remove') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.path.exists') + def test_remove_incomplete_sbom_missing_file_is_noop(self, mock_exists, mock_remove): + mock_exists.return_value = False + self.cdxgen._remove_incomplete_sbom("test_service_SBOM.json") + mock_remove.assert_not_called() + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.logger') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.remove') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.os.path.exists') + def test_remove_incomplete_sbom_handles_os_error(self, mock_exists, mock_remove, mock_logger): + mock_exists.return_value = True + mock_remove.side_effect = OSError("permission denied") + self.cdxgen._remove_incomplete_sbom("test_service_SBOM.json") + mock_logger.debug.assert_called_once_with( + "Could not remove incomplete SBOM file 'test_service_SBOM.json': permission denied" + ) + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.get_list_component') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.platform.system') + def test_get_components_break_on_build_failure_config_disabled(self, mock_platform, mock_get_list_component): + """When BREAK_ON_BUILD_FAILURE is False, _run_cdxgen receives an empty pattern list.""" + mock_platform.return_value = "Linux" + mock_get_list_component.return_value = self.mock_components + + config = { + "CDXGEN": { + "CDXGEN_VERSION": "10.2.0", + "SLIM_BINARY": False, + "OUTPUT_FORMAT": "json", + "EXCLUDE_TYPES": [], + "EXCLUDE_PATHS": [], + "RECURSE": True, + "DEBUG_PIPELINES": [], + "LIFECYCLE_PIPELINES": {}, + "BUILD_FAILURE_PATTERNS": ["BUILD FAILED"], + "BREAK_ON_BUILD_FAILURE": False + } + } + + with patch.object(self.cdxgen, '_check_cdxgen_in_path', return_value='/usr/local/bin/cdxgen'): + with patch.object(self.cdxgen, '_run_cdxgen', return_value='test_service_SBOM.json') as mock_run: + result = self.cdxgen.get_components(self.artifact, config, self.service_name) + + self.assertEqual(result, self.mock_components) + mock_run.assert_called_once_with( + '/usr/local/bin/cdxgen', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', [] + ) + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.get_list_component') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.platform.system') + def test_get_components_build_failure_patterns_passed_through(self, mock_platform, mock_get_list_component): + """All build-failure patterns come exclusively from the remote config, no built-in defaults.""" + mock_platform.return_value = "Linux" + mock_get_list_component.return_value = self.mock_components + + config = { + "CDXGEN": { + "CDXGEN_VERSION": "10.2.0", + "SLIM_BINARY": False, + "OUTPUT_FORMAT": "json", + "EXCLUDE_TYPES": [], + "EXCLUDE_PATHS": [], + "RECURSE": True, + "DEBUG_PIPELINES": [], + "LIFECYCLE_PIPELINES": {}, + "BUILD_FAILURE_PATTERNS": ["BUILD FAILED", r"npm ERR!\s+code\s+E\d+"] + } + } + + with patch.object(self.cdxgen, '_check_cdxgen_in_path', return_value='/usr/local/bin/cdxgen'): + with patch.object(self.cdxgen, '_run_cdxgen', return_value='test_service_SBOM.json') as mock_run: + result = self.cdxgen.get_components(self.artifact, config, self.service_name) + + self.assertEqual(result, self.mock_components) + mock_run.assert_called_once_with( + '/usr/local/bin/cdxgen', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', + ["BUILD FAILED", r"npm ERR!\s+code\s+E\d+"] + ) + + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.get_list_component') + @patch('devsecops_engine_tools.engine_core.src.infrastructure.driven_adapters.cdxgen.cdxgen.platform.system') + def test_get_components_no_build_failure_patterns_configured(self, mock_platform, mock_get_list_component): + """When the config does not define BUILD_FAILURE_PATTERNS, an empty list is used (no defaults).""" + mock_platform.return_value = "Linux" + mock_get_list_component.return_value = self.mock_components + + with patch.object(self.cdxgen, '_check_cdxgen_in_path', return_value='/usr/local/bin/cdxgen'): + with patch.object(self.cdxgen, '_run_cdxgen', return_value='test_service_SBOM.json') as mock_run: + result = self.cdxgen.get_components(self.artifact, self.mock_config, self.service_name) + + self.assertEqual(result, self.mock_components) + mock_run.assert_called_once_with( + '/usr/local/bin/cdxgen', self.artifact, self.service_name, [], [], True, True, {}, False, '1.6', [] + )