diff --git a/.tekton/on-pull-request.yaml b/.tekton/on-pull-request.yaml index 9ae42742..391225a6 100644 --- a/.tekton/on-pull-request.yaml +++ b/.tekton/on-pull-request.yaml @@ -159,6 +159,8 @@ spec: env: - name: TARGET_BRANCH_NAME value: "{{target_branch}}" + - name: JAVA_MAVEN_DEFAULT_SETTINGS_FILE_PATH + value: $(workspaces.source.path)/kustomize/base/settings.xml image: registry.access.redhat.com/ubi9/python-312:9.6 workingDir: $(workspaces.source.path) script: | diff --git a/kustomize/base/exploit_iq_service.yaml b/kustomize/base/exploit_iq_service.yaml index 16c17c5d..15117ed0 100644 --- a/kustomize/base/exploit_iq_service.yaml +++ b/kustomize/base/exploit_iq_service.yaml @@ -123,9 +123,13 @@ spec: fieldPath: metadata.namespace - name: GOMODCACHE value: /exploit-iq-package-cache/go/pkg/mod + - name: JAVA_MAVEN_DEFAULT_SETTINGS_FILE_PATH + value: /maven-config/settings.xml volumeMounts: - name: config mountPath: /configs + - name: maven-settings-config + mountPath: /maven-config - name: cache mountPath: /exploit-iq-data - name: package-cache @@ -137,6 +141,9 @@ spec: - name: config configMap: name: exploit-iq-config + - name: maven-settings-config + configMap: + name: exploit-iq-maven-settings-config - name: cache persistentVolumeClaim: claimName: exploit-iq-data diff --git a/kustomize/base/kustomization.yaml b/kustomize/base/kustomization.yaml index 504d3924..bbe8b25e 100644 --- a/kustomize/base/kustomization.yaml +++ b/kustomize/base/kustomization.yaml @@ -61,6 +61,9 @@ configMapGenerator: files: - excludes.json - includes.json + - name: exploit-iq-maven-settings-config + files: + - settings.xml patches: - path: ips-patch.json diff --git a/kustomize/base/settings.xml b/kustomize/base/settings.xml new file mode 100644 index 00000000..0f9572b1 --- /dev/null +++ b/kustomize/base/settings.xml @@ -0,0 +1,48 @@ + + + + + red-hat + + + red-hat-ga + https://maven.repository.redhat.com/ga + + + + + red-hat-ga + https://maven.repository.redhat.com/ga + + true + + + false + + + + + + + red-hat + + + + + + maven-default-http-blocker + Disabled default HTTP blocker + + + __no_such_repo_id__ + + + https://repo1.maven.org/maven2 + + + + \ No newline at end of file diff --git a/src/exploit_iq_commons/utils/dep_tree.py b/src/exploit_iq_commons/utils/dep_tree.py index e4f84a41..47e76fd8 100644 --- a/src/exploit_iq_commons/utils/dep_tree.py +++ b/src/exploit_iq_commons/utils/dep_tree.py @@ -843,9 +843,32 @@ def __init__(self, query: str): self._query = query def install_dependencies(self, manifest_path: Path): + settings_path = os.getenv('JAVA_MAVEN_DEFAULT_SETTINGS_FILE_PATH','../../../../kustomize/base/settings.xml') source_path = "dependencies-sources" - subprocess.run(["mvn", "dependency:copy-dependencies", "-Dclassifier=sources", - f"-DoutputDirectory={source_path}"], cwd=manifest_path) + process_object = subprocess.run(["mvn", "-s", settings_path, "dependency:copy-dependencies", "-Dclassifier=sources", + "-DincludeScope=runtime", f"-DoutputDirectory={manifest_path.resolve()}/{source_path}"], cwd=manifest_path) + + if process_object.returncode > 0: + process_object = subprocess.run(["mvn", "clean", "install", "-DskipTests", "-s", settings_path], cwd=manifest_path) + if process_object.returncode > 0: + formatted_error_msg = ( + f"Failed to build project" + f"manifest at {manifest_path}, error details => " + f"{process_object.stderr}" + ) + raise Exception(formatted_error_msg) + + process_object = subprocess.run(["mvn", "-s", settings_path, + "dependency:copy-dependencies", "-Dclassifier=sources", + "-DincludeScope=runtime", f"-DoutputDirectory={manifest_path.resolve()}/{source_path}"], cwd=manifest_path) + + if process_object.returncode > 0: + formatted_error_msg = ( + f"Failed to install dependencies" + f"manifest at {manifest_path}, error details => " + f"{process_object.stderr}" + ) + raise Exception(formatted_error_msg) full_source_path = manifest_path / source_path for jar in full_source_path.glob("*-sources.jar"): @@ -857,25 +880,59 @@ def install_dependencies(self, manifest_path: Path): zf.extractall(dest) def build_tree(self, manifest_path: Path) -> dict[str, list[str]]: + settings_path = os.getenv("JAVA_MAVEN_DEFAULT_SETTINGS_FILE_PATH", "../../../../kustomize/base/settings.xml") dependency_file = manifest_path / "dependency_tree.txt" - package_name = self._query.split(',')[0] + package_name = self._query.split(",")[0] if is_maven_gav(package_name): - with open(dependency_file, "w") as f: - subprocess.run(["mvn", "dependency:tree", - f"-Dincludes={add_missing_jar_string(package_name)}", - "-Dverbose"], cwd=manifest_path, stdout=f, check=True) + subprocess.run( + [ + "mvn", + "com.github.ferstl:depgraph-maven-plugin:4.0.3:aggregate", + "-s", settings_path, + "-DgraphFormat=text", + "-DshowGroupIds", + "-DshowVersions", + "-DshowTypes", + "-DoutputDirectory=.", + "-DoutputFileName=dependency_tree.txt", + "-DclasspathScope=runtime", + f"-DtargetIncludes={add_missing_jar_string(package_name)}", + ], + cwd=manifest_path, + check=True, + ) else: - with open(dependency_file, "w") as f: - subprocess.run(["mvn", "dependency:tree", - "-Dverbose"], cwd=manifest_path, stdout=f, check=True) + subprocess.run( + [ + "mvn", + "com.github.ferstl:depgraph-maven-plugin:4.0.3:aggregate", + "-s", settings_path, + "-DgraphFormat=text", + "-DshowGroupIds", + "-DshowVersions", + "-DshowTypes", + "-DoutputDirectory=.", + "-DclasspathScope=runtime", + "-DoutputFileName=dependency_tree.txt", + ], + cwd=manifest_path, + check=True, + ) with dependency_file.open("r", encoding="utf-8") as f: lines = f.readlines() parent, graph = self.__build_upside_down_dependency_graph(lines) - # Mark the top level for + # Mark *all* roots, not just the first one. + # A "root" is any node with no parents in the computed parent-chain list. + # This preserves old single-root behavior and fixes multi-root / multi-parent trees. + roots = [node for node, parents in graph.items() if not parents] + for r in roots: + graph[r] = [ROOT_LEVEL_SENTINEL] + + # Backward-compatible: keep the old behavior too (harmless if already set above) graph[parent] = [ROOT_LEVEL_SENTINEL] return graph @@ -885,8 +942,7 @@ def __build_upside_down_dependency_graph( ) -> Tuple[str, Dict[str, List[str]]]: root: str = "" stack: List[str] = [] - # coord -> set of direct parents (possibly multiple) - graph_sets: Dict[str, set] = {} + graph_sets: Dict[str, set[str]] = {} # coord -> set of direct parents for line in dependency_lines: depth, coord = self.__parse_dependency_line(line) @@ -894,7 +950,8 @@ def __build_upside_down_dependency_graph( continue if depth == 0: - # start (or restart) a root line + # depgraph aggregate can emit multiple top-level roots. Keep the first as "root" + # for backward compatibility, but still record others as separate roots in graph_sets. if not root: root = coord stack = [coord] @@ -907,91 +964,128 @@ def __build_upside_down_dependency_graph( parent = stack[-1] if stack else None if parent is not None: - graph_sets.setdefault(coord, set()).add(parent) + graph_sets.setdefault(coord, set()).add(parent) # supports multiple direct parents graph_sets.setdefault(parent, set()) else: graph_sets.setdefault(coord, set()) stack.append(coord) - # ---------- second phase: all parents (direct + transitive) without duplicates ---------- - def build_parent_chain(node: str) -> List[str]: """ - For a given coord, return a flat list of *all* parents reachable - via any path up to the root, with no duplicates. - - Order: breadth-first from nearest parents outward. + Return a flat list of all parents reachable via any path, no duplicates. + Deterministic BFS order: nearest parents outward. """ result: List[str] = [] seen: set[str] = set() - q = deque(graph_sets.get(node, ())) + q = deque(sorted(graph_sets.get(node, ()))) while q: - parent = q.popleft() - if parent in seen: + p = q.popleft() + if p in seen: continue - seen.add(parent) - result.append(parent) + seen.add(p) + result.append(p) - # enqueue this parent's parents - for gp in graph_sets.get(parent, ()): + for gp in sorted(graph_sets.get(p, ())): if gp not in seen: q.append(gp) return result - graph: Dict[str, List[str]] = { - coord: build_parent_chain(coord) for coord in graph_sets.keys() - } - + graph: Dict[str, List[str]] = {coord: build_parent_chain(coord) for coord in graph_sets.keys()} return root, graph def __parse_dependency_line(self, line: str) -> Tuple[Optional[int], Optional[str]]: - if not line.startswith("[INFO]"): + """ + Parse one dependency line from depgraph's graphFormat=text output. + + Expected depgraph token shape (after indentation/branch prefix): + groupId:artifactId:version:type:scope + Example from your file: + org.apache.activemq:artemis-openwire-protocol:2.28.0:bundle:compile :contentReference[oaicite:3]{index=3} + + We return (depth, "groupId:artifactId:version") and ignore type/scope/optional marker. + + Also tolerates Maven log prefixes like "[INFO] " if they appear. + """ + raw = (line or "").rstrip("\n") + if not raw.strip(): return None, None - # Keep indentation blocks; Maven prints exactly one space after "[INFO]" - s = line[6:] - if s.startswith(" "): - s = s[1:] + # If Maven stdout and depgraph output got mixed, you may see mid-line "[INFO]" injection. + # Those lines are not safely recoverable as dependency tokens. + if "[INFO]" in raw and not raw.lstrip().startswith("[INFO]"): + return None, None + + s = raw.lstrip() - # Skip non-tree lines early - if (not s - or s.startswith(("---", "BUILD", "Scanning", "Finished", "Total time")) - or ":" not in s): + # Strip Maven log prefix if present + if s.startswith("[INFO]"): + s = s[6:].lstrip() + + # Skip headers and build noise + if ( + not s + or "Dependency graph:" in s + or s.startswith(("---", "BUILD", "Reactor Summary", "Total time", "Finished at", "Scanning")) + or s.startswith("[") # other log levels like [WARNING], [ERROR], etc. + or ":" not in s + ): return None, None - # indent blocks ("| " or " ") + optional "+- " or "\- " + rest - m = re.match(r'^(?P(?:\| | )*)(?P[+\\]-\s)?(?P.+)$', s) + # depgraph indentation blocks ("| " or " ") + optional "+- " or "\- " + rest + m = re.match(r"^(?P(?:\| | )*)(?P[+\\]-\s)?(?P.+)$", s) if not m: return None, None - # Each indent block is 3 chars; add 1 if a branch token is present - depth = (len(m.group('indent')) // 3) + (1 if m.group('branch') else 0) - rest = m.group('rest').strip() + depth = (len(m.group("indent")) // 3) + (1 if m.group("branch") else 0) + rest = m.group("rest").strip() # First token up to whitespace or ')', optionally starting with '(' - m2 = re.match(r'^\(?([^\s\)]+)\)?', rest) + m2 = re.match(r"^\(?([^\s\)]+)\)?", rest) if not m2: return None, None - token = m2.group(1) # e.g., io.foo:bar:jar:1.2.3:compile - parts = token.split(':') + token = m2.group(1) # e.g. com.google.guava:guava:32.0.1-jre:jar:compile + parts = token.split(":") - # Drop trailing Maven scope if present - scopes = {'compile', 'runtime', 'test', 'provided', 'system', 'import'} + scopes = {"compile", "runtime", "test", "provided", "system", "import"} if parts and parts[-1] in scopes: parts = parts[:-1] - # Expect group:artifact:type:(classifier:)version — return without the type - if len(parts) >= 4: - group, artifact = parts[0], parts[1] - version = parts[-1] - coord = f"{group}:{artifact}:{version}" - return depth, coord + if len(parts) < 3: + return None, None + + group, artifact = parts[0], parts[1] + + # depgraph text format puts version in position 2: + # group:artifact:version:type (scope already removed) + # We detect that by checking whether the last part is a packaging/type marker. + packaging = {"jar", "war", "pom", "bundle", "maven-plugin", "ear", "ejb", "rar", "zip", "test-jar"} + + def looks_like_version(v: str) -> bool: + return any(ch.isdigit() for ch in v) + + version: Optional[str] = None + + # depgraph: group:artifact:version:type + if len(parts) >= 4 and parts[-1] in packaging and looks_like_version(parts[2]): + version = parts[2] + # depgraph (rare): group:artifact:version:type:classifier + elif len(parts) >= 5 and parts[-2] in packaging and looks_like_version(parts[2]): + version = parts[2] + else: + # Fallback for other Maven-like formats where version is last + if looks_like_version(parts[-1]): + version = parts[-1] + elif looks_like_version(parts[2]): + version = parts[2] + else: + return None, None - return None, None + coord = f"{group}:{artifact}:{version}" + return depth, coord class PythonDependencyTreeBuilder(DependencyTreeBuilder): diff --git a/src/vuln_analysis/tools/tests/test_transitive_code_search.py b/src/vuln_analysis/tools/tests/test_transitive_code_search.py index 80b881ea..9826ac1e 100644 --- a/src/vuln_analysis/tools/tests/test_transitive_code_search.py +++ b/src/vuln_analysis/tools/tests/test_transitive_code_search.py @@ -448,4 +448,152 @@ async def test_transitive_search_java_5(): (path_found, list_path) = result print(result) assert path_found is False - assert len(list_path) is 1 \ No newline at end of file + assert len(list_path) is 1 + +# CVE-2025-48734 - https://github.com/rh-messaging/activemq-artemis - Reachable +@pytest.mark.asyncio +async def test_transitive_search_java_11(): + transitive_code_search_runner_coroutine = await get_transitive_code_runner_function() + set_input_for_next_run(git_repository="https://github.com/rh-messaging/activemq-artemis", + git_ref="7.11.4.CR1", + included_extensions=["**/*.java"], + excluded_extensions=["target/**/*", + "build/**/*", + "*.class", + ".gradle/**/*", + ".mvn/**/*", + ".gitignore", + "test/**/*", + "tests/**/*", + "src/test/**/*", + "pom.xml", + "build.gradle"]) + result = await transitive_code_search_runner_coroutine("commons-beanutils:commons-beanutils:1.9.4,org.apache.commons.beanutils.PropertyUtilsBean.getProperty") + (path_found, list_path) = result + print(result) + assert path_found is True + assert len(list_path) > 1 + document = list_path[-1] + assert ('activemq' in document.metadata['source']) or ('artemis' in document.metadata['source']) + +# CVE-2025-58057 - https://github.com/rh-messaging/activemq-artemis - Not Reachable +@pytest.mark.asyncio +async def test_transitive_search_java_12(): + transitive_code_search_runner_coroutine = await get_transitive_code_runner_function() + set_input_for_next_run(git_repository="https://github.com/rh-messaging/activemq-artemis", + git_ref="7.11.4.CR1", + included_extensions=["**/*.java"], + excluded_extensions=["target/**/*", + "build/**/*", + "*.class", + ".gradle/**/*", + ".mvn/**/*", + ".gitignore", + "test/**/*", + "tests/**/*", + "src/test/**/*", + "pom.xml", + "build.gradle"]) + result = await transitive_code_search_runner_coroutine("io.netty:netty-codec:4.1.119.Final,io.netty.handler.codec.compression.BrotliDecoder.decode") + (path_found, list_path) = result + print(result) + assert path_found is False + assert len(list_path) is 1 + +# CVE-2023-1370 - https://github.com/rh-messaging/activemq-artemis - Not Reachable +@pytest.mark.asyncio +async def test_transitive_search_java_13(): + transitive_code_search_runner_coroutine = await get_transitive_code_runner_function() + set_input_for_next_run(git_repository="https://github.com/rh-messaging/activemq-artemis", + git_ref="7.11.4.CR1", + included_extensions=["**/*.java"], + excluded_extensions=["target/**/*", + "build/**/*", + "*.class", + ".gradle/**/*", + ".mvn/**/*", + ".gitignore", + "test/**/*", + "tests/**/*", + "src/test/**/*", + "pom.xml", + "build.gradle"]) + result = await transitive_code_search_runner_coroutine("net.minidev:json-smart:2.4.9,net.minidev.json.parser.JSONParser.parse") + (path_found, list_path) = result + print(result) + assert path_found is False + assert len(list_path) is 1 + +# CVE-2019-10086 - https://github.com/rh-messaging/activemq-artemis - Not Reachable +@pytest.mark.asyncio +async def test_transitive_search_java_14(): + transitive_code_search_runner_coroutine = await get_transitive_code_runner_function() + set_input_for_next_run(git_repository="https://github.com/rh-messaging/activemq-artemis", + git_ref="7.11.4.CR1", + included_extensions=["**/*.java"], + excluded_extensions=["target/**/*", + "build/**/*", + "*.class", + ".gradle/**/*", + ".mvn/**/*", + ".gitignore", + "test/**/*", + "tests/**/*", + "src/test/**/*", + "pom.xml", + "build.gradle"]) + result = await transitive_code_search_runner_coroutine("commons-beanutils:commons-beanutils:1.9.2,org.apache.commons.beanutils.PropertyUtilsBean.getProperty") + (path_found, list_path) = result + print(result) + assert path_found is False + assert len(list_path) is 1 + +# CVE-2025-24970 - https://github.com/rh-messaging/activemq-artemis - Not Reachable +@pytest.mark.asyncio +async def test_transitive_search_java_15(): + transitive_code_search_runner_coroutine = await get_transitive_code_runner_function() + set_input_for_next_run(git_repository="https://github.com/rh-messaging/activemq-artemis", + git_ref="7.11.4.CR1", + included_extensions=["**/*.java"], + excluded_extensions=["target/**/*", + "build/**/*", + "*.class", + ".gradle/**/*", + ".mvn/**/*", + ".gitignore", + "test/**/*", + "tests/**/*", + "src/test/**/*", + "pom.xml", + "build.gradle"]) + result = await transitive_code_search_runner_coroutine("io.netty:netty-handler:4.1.86.Final-redhat-00001,io.netty.handler.ssl.SslContext.newHandler") + (path_found, list_path) = result + print(result) + assert path_found is False + assert len(list_path) is 1 + +# CVE-2024-8184 - https://github.com/rh-messaging/activemq-artemis - Reachable +@pytest.mark.asyncio +async def test_transitive_search_java_16(): + transitive_code_search_runner_coroutine = await get_transitive_code_runner_function() + set_input_for_next_run(git_repository="https://github.com/rh-messaging/activemq-artemis", + git_ref="7.11.4.CR1", + included_extensions=["**/*.java"], + excluded_extensions=["target/**/*", + "build/**/*", + "*.class", + ".gradle/**/*", + ".mvn/**/*", + ".gitignore", + "test/**/*", + "tests/**/*", + "src/test/**/*", + "pom.xml", + "build.gradle"]) + result = await transitive_code_search_runner_coroutine("org.eclipse.jetty:jetty-server:10.0.16,org.eclipse.jetty.server.handler.ThreadLimitHandler.getRemote") + (path_found, list_path) = result + print(result) + assert path_found is True + assert len(list_path) > 1 + document = list_path[-1] + assert ('activemq' in document.metadata['source']) or ('artemis' in document.metadata['source']) \ No newline at end of file