diff --git a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py index 740ccca9..53ed269f 100644 --- a/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_maven_dependencies.py @@ -1,3 +1,4 @@ +import json from os import path from pathlib import Path from typing import Optional @@ -20,6 +21,16 @@ MAVEN_DEP_TREE_FILE_NAME = 'bcde.mvndeps' +def _has_dependency_graph(bom_content: Optional[str]) -> bool: + try: + if not bom_content: + return False + bom = json.loads(bom_content) + return any(dep.get('dependsOn') for dep in bom.get('dependencies', [])) + except Exception: + return False + + class RestoreMavenDependencies(BaseRestoreDependencies): def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(ctx, is_git_diff, command_timeout) @@ -46,8 +57,16 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if document.content is None: return self.restore_from_secondary_command(document, manifest_file_path) - # super() reads the content and cleans up any generated file; no re-read needed - return super().try_restore_dependencies(document) + restore_dependencies_document = super().try_restore_dependencies(document) + if restore_dependencies_document is None: + return None + + if not _has_dependency_graph(restore_dependencies_document.content): + fallback = self.restore_from_secondary_command(document, manifest_file_path) + if fallback is not None and fallback.content is not None: + return fallback + + return restore_dependencies_document def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: restore_content = execute_commands( diff --git a/tests/cli/files_collector/sca/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py new file mode 100644 index 00000000..fc49cb91 --- /dev/null +++ b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py @@ -0,0 +1,105 @@ +import json +from unittest.mock import MagicMock, patch + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + RestoreMavenDependencies, + _has_dependency_graph, +) +from cycode.cli.models import Document + + +class TestHasDependencyGraph: + def test_returns_false_when_content_is_none(self) -> None: + assert _has_dependency_graph(None) is False + + def test_returns_false_when_content_is_empty_string(self) -> None: + assert _has_dependency_graph('') is False + + def test_returns_false_when_dependencies_section_is_missing(self) -> None: + content = json.dumps({'components': [{'name': 'foo'}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_all_dependencies_have_empty_depends_on(self) -> None: + content = json.dumps({'dependencies': [{'ref': 'pkg:maven/foo/bar@1.0', 'dependsOn': []}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_dependencies_list_is_empty(self) -> None: + content = json.dumps({'dependencies': []}) + assert _has_dependency_graph(content) is False + + def test_returns_true_when_at_least_one_dependency_has_depends_on(self) -> None: + content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty-all@4.1.0']}, + {'ref': 'pkg:maven/io.netty/netty-all@4.1.0', 'dependsOn': []}, + ] + } + ) + assert _has_dependency_graph(content) is True + + def test_returns_false_when_content_is_invalid_json(self) -> None: + assert _has_dependency_graph('not valid json {{{') is False + + +class TestRestoreMavenDependenciesFallback: + def _make_instance(self) -> RestoreMavenDependencies: + ctx = MagicMock() + ctx.obj = {} + return RestoreMavenDependencies(ctx=ctx, is_git_diff=False, command_timeout=60) + + def test_falls_back_to_secondary_command_when_bom_has_no_dependency_graph(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps({'dependencies': []}) + fallback_doc = MagicMock(spec=Document) + fallback_doc.content = '[INFO] com.example:root:jar:1.0\n+- io.netty:netty-all:jar:4.1.0' + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command', return_value=fallback_doc) as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_called_once_with(document, '/project/pom.xml') + assert result is fallback_doc + + def test_returns_bom_document_when_dependency_graph_is_present(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty@4.1.0']} + ] + } + ) + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command') as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_not_called() + assert result is bom_doc + + def test_uses_plugin_version_2_9_1(self) -> None: + instance = self._make_instance() + commands = instance.get_commands('/path/to/pom.xml') + assert len(commands) == 1 + assert 'org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom' in commands[0]