From 5625902f391e4b4f2cc57a8882cea507eba1cc72 Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Thu, 21 May 2026 13:57:12 +0100 Subject: [PATCH 1/2] fix(maven): resolve version ranges in component analysis Maven version ranges (e.g. `[1.2.17,1.3.0)`, `(1.0,2.0]`) in pom.xml dependency versions are preserved verbatim by `mvn help:effective-pom`. Component analysis uses the effective POM to extract dependency versions, so range expressions end up as the version in the PackageURL. The backend cannot look up vulnerabilities for a range-valued purl, so these dependencies were silently dropped from analysis results. Stack analysis was unaffected because it uses `mvn dependency:tree`, which resolves ranges to concrete versions before output. Add `resolveVersionRanges` to detect range-valued versions in the effective POM output and resolve them by running `mvn dependency:tree -DoutputType=text -Dscope=compile`, parsing the direct dependencies (depth 1) to extract the concrete versions Maven selects. The resolution is guarded: it only runs when at least one dependency has a version range, and falls back to the original range values if the tree invocation fails. Ref: https://github.com/fabric8-analytics/fabric8-analytics-vscode-extension/issues/812 Co-Authored-By: Claude Opus 4.6 --- .../providers/JavaMavenProvider.java | 78 ++++++ .../providers/Java_Maven_Provider_Test.java | 45 +++- .../maven/deps_with_version_range/depTree.txt | 3 + .../deps_with_version_range/effectivePom.xml | 252 ++++++++++++++++++ .../expected_component_sbom.json | 51 ++++ .../expected_stack_sbom.json | 51 ++++ .../maven/deps_with_version_range/pom.xml | 22 ++ 7 files changed, 497 insertions(+), 5 deletions(-) create mode 100644 src/test/resources/tst_manifests/maven/deps_with_version_range/depTree.txt create mode 100644 src/test/resources/tst_manifests/maven/deps_with_version_range/effectivePom.xml create mode 100644 src/test/resources/tst_manifests/maven/deps_with_version_range/expected_component_sbom.json create mode 100644 src/test/resources/tst_manifests/maven/deps_with_version_range/expected_stack_sbom.json create mode 100644 src/test/resources/tst_manifests/maven/deps_with_version_range/pom.xml 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..7b798b33 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java @@ -36,6 +36,7 @@ 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; @@ -228,6 +229,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 +241,82 @@ 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( + "org.apache.maven.plugins:maven-dependency-plugin:3.6.0:tree", + "-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.warning( + String.format( + "Failed to resolve version ranges via dependency tree, " + + "using original versions: %s", + e.getMessage())); + return deps; + } finally { + Files.deleteIfExists(tmpFile); + } + + 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..22e21060 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 @@ -56,7 +56,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 +144,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 +207,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 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..15f020c9 --- /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 + + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/main/java + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/main/scripts + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/test/java + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/classes + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/test-classes + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/main/resources + + + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/test/resources + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/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 + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/site + + + org.apache.maven.plugins + maven-project-info-reports-plugin + + + + + + default-deploy + site-deploy + + deploy + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/site + + + org.apache.maven.plugins + maven-project-info-reports-plugin + + + + + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/site + + + org.apache.maven.plugins + maven-project-info-reports-plugin + + + + + + + + /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/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 + + + From 6ef8aa98b7a0f815b27b76b256ca37e1d3221e2d Mon Sep 17 00:00:00 2001 From: Noah Santschi-Cooney Date: Wed, 3 Jun 2026 13:48:13 +0100 Subject: [PATCH 2/2] fix(maven): address PR #491 review feedback - Extract DEP_TREE_PLUGIN constant to avoid duplicated GAV string - Wrap Files.deleteIfExists in finally block with try/catch to prevent IOException from violating the "return original list" fallback contract - Log full exception (with stack trace) instead of just e.getMessage() - Replace absolute filesystem paths in effectivePom.xml fixture with relative paths - Add failure-path test verifying graceful fallback when dependency:tree invocation fails Co-Authored-By: Claude Opus 4.6 --- .../providers/JavaMavenProvider.java | 18 +++++--- .../providers/Java_Maven_Provider_Test.java | 43 +++++++++++++++++++ .../deps_with_version_range/effectivePom.xml | 24 +++++------ 3 files changed, 68 insertions(+), 17 deletions(-) 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 7b798b33..dd0464b0 100644 --- a/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java +++ b/src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java @@ -41,6 +41,7 @@ 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; @@ -60,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); @@ -136,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", @@ -268,7 +271,7 @@ private List resolveVersionRanges(List resolveVersionRanges(List 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/effectivePom.xml b/src/test/resources/tst_manifests/maven/deps_with_version_range/effectivePom.xml index 15f020c9..341881bd 100644 --- 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 @@ -54,22 +54,22 @@ - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/main/java - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/main/scripts - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/test/java - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/classes - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/test-classes + src/main/java + src/main/scripts + src/test/java + target/classes + target/test-classes - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/main/resources + src/main/resources - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/src/test/resources + src/test/resources - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target + target pom-with-version-range-for-tests-0.0.1 @@ -208,7 +208,7 @@ site - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/site + target/site org.apache.maven.plugins @@ -224,7 +224,7 @@ deploy - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/site + target/site org.apache.maven.plugins @@ -235,7 +235,7 @@ - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/site + target/site org.apache.maven.plugins @@ -247,6 +247,6 @@ - /home/zgrinber/git/trustify-da-java-client/src/test/resources/tst_manifests/maven/deps_with_version_range/target/site + target/site