diff --git a/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java b/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java index da01e03b..dd0464b0 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java @@ -36,10 +36,12 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.xml.stream.XMLInputFactory; @@ -59,6 +61,8 @@ public final class JavaMavenProvider extends BaseJavaProvider { private final String mvnExecutable; private static final String MVN = Operations.isWindows() ? "mvn.cmd" : "mvn"; private static final String ARG_VERSION = "-v"; + private static final String DEP_TREE_PLUGIN = + "org.apache.maven.plugins:maven-dependency-plugin:3.6.0:tree"; public JavaMavenProvider(Path manifest) { super(Type.MAVEN, manifest); @@ -135,7 +139,7 @@ public Content provideStack() throws IOException { var mvnTreeCmdArgs = new ArrayList<>( List.of( - "org.apache.maven.plugins:maven-dependency-plugin:3.6.0:tree", + DEP_TREE_PLUGIN, "-Dscope=compile", "-Dverbose", "-DoutputType=text", @@ -228,6 +232,7 @@ private Content generateSbomFromEffectivePom() throws IOException { .filter(DependencyAggregator::isTestDependency) .collect(Collectors.toSet()); var deps = getDependencies(tmpEffPom); + deps = resolveVersionRanges(deps); var sbom = SbomFactory.newInstance().addRoot(getRoot(tmpEffPom), readLicenseFromManifest()); deps.stream() .filter(dep -> !testsDeps.contains(dep)) @@ -239,6 +244,87 @@ private Content generateSbomFromEffectivePom() throws IOException { return new Content(sbom.getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE); } + /** + * Checks whether the given version string is a Maven version range expression (starts with '[' or + * '('). + */ + private static boolean isVersionRange(String version) { + if (version == null || version.isEmpty()) return false; + char first = version.charAt(0); + return first == '[' || first == '('; + } + + /** + * Resolves Maven version ranges by running the dependency tree plugin and replacing range + * expressions with concrete resolved versions. If no version ranges are present, the original + * list is returned unchanged. On failure, the original list is returned with a warning logged. + */ + private List resolveVersionRanges(List deps) + throws IOException { + // Short-circuit: if no dep has a version range, return unchanged + boolean hasRanges = deps.stream().anyMatch(d -> isVersionRange(d.version)); + if (!hasRanges) { + return deps; + } + + Path tmpFile = Files.createTempFile("TRUSTIFY_DA_range_tree_", ".txt"); + try { + var cmd = + buildMvnCommandArgs( + DEP_TREE_PLUGIN, + "-Dscope=compile", + "-DoutputType=text", + String.format("-DoutputFile=%s", tmpFile.toString()), + "-f", + manifestPath.toString(), + "--batch-mode", + "-q"); + Operations.runProcess(manifestPath.getParent(), cmd.toArray(String[]::new), getMvnExecEnvs()); + + // Read the dependency tree output and build a lookup map of resolved versions + List lines = Files.readAllLines(tmpFile); + Map resolvedVersions = new HashMap<>(); + for (String line : lines) { + if (getDepth(line) == 1) { + DependencyAggregator resolved = parseDep(line); + resolvedVersions.put(resolved.groupId + ":" + resolved.artifactId, resolved.version); + } + } + + // Replace version ranges with resolved concrete versions + for (DependencyAggregator dep : deps) { + if (isVersionRange(dep.version)) { + String key = dep.groupId + ":" + dep.artifactId; + String resolved = resolvedVersions.get(key); + if (resolved != null) { + if (debugLoggingIsNeeded()) { + log.info( + String.format( + "Resolved version range for %s: %s -> %s", key, dep.version, resolved)); + } + dep.version = resolved; + } + } + } + } catch (Exception e) { + log.log( + Level.WARNING, + String.format( + "Failed to resolve version ranges via dependency tree, " + + "using original versions: %s", + e.getMessage()), + e); + return deps; + } finally { + try { + Files.deleteIfExists(tmpFile); + } catch (IOException ignored) { + } + } + + return deps; + } + private PackageURL getRoot(final Path manifestPath) throws IOException { XMLStreamReader reader = null; try { diff --git a/src/test/java/io/github/guacsec/trustifyda/providers/Java_Maven_Provider_Test.java b/src/test/java/io/github/guacsec/trustifyda/providers/Java_Maven_Provider_Test.java index ed70a06d..1a982ece 100644 --- a/src/test/java/io/github/guacsec/trustifyda/providers/Java_Maven_Provider_Test.java +++ b/src/test/java/io/github/guacsec/trustifyda/providers/Java_Maven_Provider_Test.java @@ -30,6 +30,7 @@ import java.util.Arrays; import java.util.Optional; import java.util.stream.Stream; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -56,7 +57,8 @@ static Stream testFolders() { "deps_with_ignore_on_version", "deps_with_ignore_on_wrong", "deps_with_no_ignore", - "pom_deps_with_no_ignore_common_paths"); + "pom_deps_with_no_ignore_common_paths", + "deps_with_version_range"); } @ParameterizedTest @@ -143,12 +145,29 @@ void test_the_provideComponent(String testFolder) throws IOException { getClass(), String.format("tst_manifests/maven/%s/effectivePom.xml", testFolder))) { effectivePom = new String(is.readAllBytes()); } + + String depTree; + try (var is = + getResourceAsStreamDecision( + getClass(), String.format("tst_manifests/maven/%s/depTree.txt", testFolder))) { + depTree = new String(is.readAllBytes()); + } + try (MockedStatic mockedOperations = mockStatic(Operations.class)) { mockedOperations .when(() -> Operations.runProcess(any(), any(), any())) .thenAnswer( - invocationOnMock -> - getOutputFileAndOverwriteItWithMock(effectivePom, invocationOnMock, "-Doutput")); + invocationOnMock -> { + String result = + getOutputFileAndOverwriteItWithMock( + effectivePom, invocationOnMock, "-Doutput="); + if (result == null) { + result = + getOutputFileAndOverwriteItWithMock( + depTree, invocationOnMock, "-DoutputFile"); + } + return result; + }); // Mock Operations.getCustomPathOrElse to return "mvn" mockedOperations.when(() -> Operations.getCustomPathOrElse(anyString())).thenReturn("mvn"); mockedOperations @@ -189,12 +208,29 @@ void test_the_provideComponent_With_Path(String testFolder) throws IOException { getClass(), String.format("tst_manifests/maven/%s/effectivePom.xml", testFolder))) { effectivePom = new String(is.readAllBytes()); } + + String depTree; + try (var is = + getResourceAsStreamDecision( + getClass(), String.format("tst_manifests/maven/%s/depTree.txt", testFolder))) { + depTree = new String(is.readAllBytes()); + } + try (MockedStatic mockedOperations = mockStatic(Operations.class)) { mockedOperations .when(() -> Operations.runProcess(any(), any(), any())) .thenAnswer( - invocationOnMock -> - getOutputFileAndOverwriteItWithMock(effectivePom, invocationOnMock, "-Doutput")); + invocationOnMock -> { + String result = + getOutputFileAndOverwriteItWithMock( + effectivePom, invocationOnMock, "-Doutput="); + if (result == null) { + result = + getOutputFileAndOverwriteItWithMock( + depTree, invocationOnMock, "-DoutputFile"); + } + return result; + }); // Mock Operations.getCustomPathOrElse to return "mvn" mockedOperations.when(() -> Operations.getCustomPathOrElse(anyString())).thenReturn("mvn"); mockedOperations @@ -209,6 +245,48 @@ void test_the_provideComponent_With_Path(String testFolder) throws IOException { } } + @Test + void test_provideComponent_fallsBack_when_depTree_fails() throws IOException { + String testFolder = "deps_with_version_range"; + + var targetPom = resolveFile(String.format("tst_manifests/maven/%s/pom.xml", testFolder)); + + String effectivePom; + try (var is = + getResourceAsStreamDecision( + getClass(), String.format("tst_manifests/maven/%s/effectivePom.xml", testFolder))) { + effectivePom = new String(is.readAllBytes()); + } + + try (MockedStatic mockedOperations = mockStatic(Operations.class)) { + mockedOperations + .when(() -> Operations.runProcess(any(), any(), any())) + .thenAnswer( + invocationOnMock -> { + String result = + getOutputFileAndOverwriteItWithMock( + effectivePom, invocationOnMock, "-Doutput="); + if (result == null) { + throw new IOException("Simulated dependency:tree failure"); + } + return result; + }); + mockedOperations.when(() -> Operations.getCustomPathOrElse(anyString())).thenReturn("mvn"); + mockedOperations + .when(() -> Operations.getExecutable(anyString(), anyString())) + .thenReturn("mvn"); + + var content = new JavaMavenProvider(targetPom).provideComponent(); + + assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE); + String sbom = new String(content.buffer); + assertThat(sbom).contains("log4j"); + assertThat(sbom).contains("snappy-java"); + // Version range should remain unresolved since dep tree failed + assertThat(sbom).contains("[1.2.17,1.3.0)"); + } + } + private String dropIgnored(String s) { return s.replaceAll("\\s+", "").replaceAll("\"timestamp\":\"[a-zA-Z0-9\\-\\:]+\",", ""); } diff --git a/src/test/resources/tst_manifests/maven/deps_with_version_range/depTree.txt b/src/test/resources/tst_manifests/maven/deps_with_version_range/depTree.txt new file mode 100644 index 00000000..761020ff --- /dev/null +++ b/src/test/resources/tst_manifests/maven/deps_with_version_range/depTree.txt @@ -0,0 +1,3 @@ +pom-with-deps-version-range:pom-with-version-range-for-tests:jar:0.0.1 ++- log4j:log4j:jar:1.2.17:compile +\- org.xerial.snappy:snappy-java:jar:1.1.10.0:compile diff --git a/src/test/resources/tst_manifests/maven/deps_with_version_range/effectivePom.xml b/src/test/resources/tst_manifests/maven/deps_with_version_range/effectivePom.xml new file mode 100644 index 00000000..341881bd --- /dev/null +++ b/src/test/resources/tst_manifests/maven/deps_with_version_range/effectivePom.xml @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + 4.0.0 + pom-with-deps-version-range + pom-with-version-range-for-tests + 0.0.1 + + + log4j + log4j + [1.2.17,1.3.0) + compile + + + org.xerial.snappy + snappy-java + 1.1.10.0 + compile + + + + + + false + + central + Central Repository + https://repo.maven.apache.org/maven2 + + + + + + never + + + false + + central + Central Repository + https://repo.maven.apache.org/maven2 + + + + src/main/java + src/main/scripts + src/test/java + target/classes + target/test-classes + + + src/main/resources + + + + + src/test/resources + + + target + pom-with-version-range-for-tests-0.0.1 + + + + maven-antrun-plugin + 1.3 + + + maven-assembly-plugin + 2.2-beta-5 + + + maven-dependency-plugin + 2.8 + + + maven-release-plugin + 2.5.3 + + + + + + maven-clean-plugin + 2.5 + + + default-clean + clean + + clean + + + + + + maven-resources-plugin + 2.6 + + + default-testResources + process-test-resources + + testResources + + + + default-resources + process-resources + + resources + + + + + + maven-jar-plugin + 2.4 + + + default-jar + package + + jar + + + + + + maven-compiler-plugin + 3.1 + + + default-compile + compile + + compile + + + + default-testCompile + test-compile + + testCompile + + + + + + maven-surefire-plugin + 2.12.4 + + + default-test + test + + test + + + + + + maven-install-plugin + 2.4 + + + default-install + install + + install + + + + + + maven-deploy-plugin + 2.7 + + + default-deploy + deploy + + deploy + + + + + + maven-site-plugin + 3.3 + + + default-site + site + + site + + + target/site + + + org.apache.maven.plugins + maven-project-info-reports-plugin + + + + + + default-deploy + site-deploy + + deploy + + + target/site + + + org.apache.maven.plugins + maven-project-info-reports-plugin + + + + + + + target/site + + + org.apache.maven.plugins + maven-project-info-reports-plugin + + + + + + + + target/site + + diff --git a/src/test/resources/tst_manifests/maven/deps_with_version_range/expected_component_sbom.json b/src/test/resources/tst_manifests/maven/deps_with_version_range/expected_component_sbom.json new file mode 100644 index 00000000..753f4abe --- /dev/null +++ b/src/test/resources/tst_manifests/maven/deps_with_version_range/expected_component_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat" : "CycloneDX", + "specVersion" : "1.4", + "version" : 1, + "metadata" : { + "timestamp" : "2025-04-09T12:15:45Z", + "component" : { + "type" : "application", + "bom-ref" : "pkg:maven/pom-with-deps-version-range/pom-with-version-range-for-tests@0.0.1", + "group" : "pom-with-deps-version-range", + "name" : "pom-with-version-range-for-tests", + "version" : "0.0.1", + "purl" : "pkg:maven/pom-with-deps-version-range/pom-with-version-range-for-tests@0.0.1" + } + }, + "components" : [ + { + "type" : "library", + "bom-ref" : "pkg:maven/log4j/log4j@1.2.17", + "group" : "log4j", + "name" : "log4j", + "version" : "1.2.17", + "purl" : "pkg:maven/log4j/log4j@1.2.17" + }, + { + "type" : "library", + "bom-ref" : "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0", + "group" : "org.xerial.snappy", + "name" : "snappy-java", + "version" : "1.1.10.0", + "purl" : "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0" + } + ], + "dependencies" : [ + { + "ref" : "pkg:maven/pom-with-deps-version-range/pom-with-version-range-for-tests@0.0.1", + "dependsOn" : [ + "pkg:maven/log4j/log4j@1.2.17", + "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0" + ] + }, + { + "ref" : "pkg:maven/log4j/log4j@1.2.17", + "dependsOn" : [ ] + }, + { + "ref" : "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0", + "dependsOn" : [ ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/tst_manifests/maven/deps_with_version_range/expected_stack_sbom.json b/src/test/resources/tst_manifests/maven/deps_with_version_range/expected_stack_sbom.json new file mode 100644 index 00000000..fc3489fb --- /dev/null +++ b/src/test/resources/tst_manifests/maven/deps_with_version_range/expected_stack_sbom.json @@ -0,0 +1,51 @@ +{ + "bomFormat" : "CycloneDX", + "specVersion" : "1.4", + "version" : 1, + "metadata" : { + "timestamp" : "2025-04-09T12:14:35Z", + "component" : { + "type" : "application", + "bom-ref" : "pkg:maven/pom-with-deps-version-range/pom-with-version-range-for-tests@0.0.1", + "group" : "pom-with-deps-version-range", + "name" : "pom-with-version-range-for-tests", + "version" : "0.0.1", + "purl" : "pkg:maven/pom-with-deps-version-range/pom-with-version-range-for-tests@0.0.1" + } + }, + "components" : [ + { + "type" : "library", + "bom-ref" : "pkg:maven/log4j/log4j@1.2.17", + "group" : "log4j", + "name" : "log4j", + "version" : "1.2.17", + "purl" : "pkg:maven/log4j/log4j@1.2.17" + }, + { + "type" : "library", + "bom-ref" : "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0", + "group" : "org.xerial.snappy", + "name" : "snappy-java", + "version" : "1.1.10.0", + "purl" : "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0" + } + ], + "dependencies" : [ + { + "ref" : "pkg:maven/pom-with-deps-version-range/pom-with-version-range-for-tests@0.0.1", + "dependsOn" : [ + "pkg:maven/log4j/log4j@1.2.17", + "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0" + ] + }, + { + "ref" : "pkg:maven/log4j/log4j@1.2.17", + "dependsOn" : [ ] + }, + { + "ref" : "pkg:maven/org.xerial.snappy/snappy-java@1.1.10.0", + "dependsOn" : [ ] + } + ] +} \ No newline at end of file diff --git a/src/test/resources/tst_manifests/maven/deps_with_version_range/pom.xml b/src/test/resources/tst_manifests/maven/deps_with_version_range/pom.xml new file mode 100644 index 00000000..cb277e06 --- /dev/null +++ b/src/test/resources/tst_manifests/maven/deps_with_version_range/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + pom-with-deps-version-range + pom-with-version-range-for-tests + 0.0.1 + + + + log4j + log4j + [1.2.17,1.3.0) + + + org.xerial.snappy + snappy-java + 1.1.10.0 + + +