Skip to content

Commit 7c9c9a5

Browse files
Strum355claude
andauthored
fix(maven): resolve version ranges in component analysis (#491)
## Summary 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 (e.g. `pkg:maven/log4j/log4j@[1.2.17,1.3.0)`). The backend cannot look up vulnerabilities for a range-valued purl, so these dependencies are silently dropped — no editor diagnostics appear for them. Stack analysis is unaffected because it uses `mvn dependency:tree`, which resolves ranges to concrete versions before output. ## How it works After extracting dependencies from the effective POM, `resolveVersionRanges` checks whether any dependency has a version range (starts with `[` or `(`). If none do, it returns immediately — no performance impact for normal projects. When ranges are detected, it runs `mvn dependency:tree -DoutputType=text -Dscope=compile` and parses the direct dependencies (depth 1) from the text output using the existing `getDepth()` and `parseDep()` methods. Each resolved `groupId:artifactId → version` mapping is collected, and range-valued versions in the dependency list are replaced with the concrete resolved versions before SBOM generation. If the dependency tree invocation fails for any reason, a warning is logged and the method falls back to the original unresolved versions, so the overall flow is never broken. ## Changes - **`JavaMavenProvider.java`** — Add `isVersionRange` and `resolveVersionRanges` methods. Call `resolveVersionRanges` in `generateSbomFromEffectivePom` after extracting deps from the effective POM. - **`Java_Maven_Provider_Test.java`** — Add `deps_with_version_range` test folder. Update `test_the_provideComponent` and `test_the_provideComponent_With_Path` mocks to dispatch both `-Doutput=` (effective POM) and `-DoutputFile` (dep tree) arguments, with `-Doutput=` changed from `-Doutput` to avoid matching `-DoutputFile` and `-DoutputType`. - **`src/test/resources/tst_manifests/maven/deps_with_version_range/`** — New fixture with a range-valued dependency (`log4j [1.2.17,1.3.0)`), verifying both stack and component analysis resolve the range to `1.2.17`. ## Test plan - [x] All existing Maven provider tests pass (stack + component analysis + component with path) - [x] New version range test passes for all three test methods - [x] Companion JS client PR: guacsec/trustify-da-javascript-client#536 Ref: fabric8-analytics/fabric8-analytics-vscode-extension#812 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0cce5c0 commit 7c9c9a5

7 files changed

Lines changed: 549 additions & 6 deletions

File tree

src/main/java/io/github/guacsec/trustifyda/providers/JavaMavenProvider.java

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,12 @@
3636
import java.nio.file.Paths;
3737
import java.util.ArrayList;
3838
import java.util.Collections;
39+
import java.util.HashMap;
3940
import java.util.List;
4041
import java.util.Map;
4142
import java.util.Objects;
4243
import java.util.Optional;
44+
import java.util.logging.Level;
4345
import java.util.logging.Logger;
4446
import java.util.stream.Collectors;
4547
import javax.xml.stream.XMLInputFactory;
@@ -59,6 +61,8 @@ public final class JavaMavenProvider extends BaseJavaProvider {
5961
private final String mvnExecutable;
6062
private static final String MVN = Operations.isWindows() ? "mvn.cmd" : "mvn";
6163
private static final String ARG_VERSION = "-v";
64+
private static final String DEP_TREE_PLUGIN =
65+
"org.apache.maven.plugins:maven-dependency-plugin:3.6.0:tree";
6266

6367
public JavaMavenProvider(Path manifest) {
6468
super(Type.MAVEN, manifest);
@@ -135,7 +139,7 @@ public Content provideStack() throws IOException {
135139
var mvnTreeCmdArgs =
136140
new ArrayList<>(
137141
List.of(
138-
"org.apache.maven.plugins:maven-dependency-plugin:3.6.0:tree",
142+
DEP_TREE_PLUGIN,
139143
"-Dscope=compile",
140144
"-Dverbose",
141145
"-DoutputType=text",
@@ -228,6 +232,7 @@ private Content generateSbomFromEffectivePom() throws IOException {
228232
.filter(DependencyAggregator::isTestDependency)
229233
.collect(Collectors.toSet());
230234
var deps = getDependencies(tmpEffPom);
235+
deps = resolveVersionRanges(deps);
231236
var sbom = SbomFactory.newInstance().addRoot(getRoot(tmpEffPom), readLicenseFromManifest());
232237
deps.stream()
233238
.filter(dep -> !testsDeps.contains(dep))
@@ -239,6 +244,87 @@ private Content generateSbomFromEffectivePom() throws IOException {
239244
return new Content(sbom.getAsJsonString().getBytes(), Api.CYCLONEDX_MEDIA_TYPE);
240245
}
241246

247+
/**
248+
* Checks whether the given version string is a Maven version range expression (starts with '[' or
249+
* '(').
250+
*/
251+
private static boolean isVersionRange(String version) {
252+
if (version == null || version.isEmpty()) return false;
253+
char first = version.charAt(0);
254+
return first == '[' || first == '(';
255+
}
256+
257+
/**
258+
* Resolves Maven version ranges by running the dependency tree plugin and replacing range
259+
* expressions with concrete resolved versions. If no version ranges are present, the original
260+
* list is returned unchanged. On failure, the original list is returned with a warning logged.
261+
*/
262+
private List<DependencyAggregator> resolveVersionRanges(List<DependencyAggregator> deps)
263+
throws IOException {
264+
// Short-circuit: if no dep has a version range, return unchanged
265+
boolean hasRanges = deps.stream().anyMatch(d -> isVersionRange(d.version));
266+
if (!hasRanges) {
267+
return deps;
268+
}
269+
270+
Path tmpFile = Files.createTempFile("TRUSTIFY_DA_range_tree_", ".txt");
271+
try {
272+
var cmd =
273+
buildMvnCommandArgs(
274+
DEP_TREE_PLUGIN,
275+
"-Dscope=compile",
276+
"-DoutputType=text",
277+
String.format("-DoutputFile=%s", tmpFile.toString()),
278+
"-f",
279+
manifestPath.toString(),
280+
"--batch-mode",
281+
"-q");
282+
Operations.runProcess(manifestPath.getParent(), cmd.toArray(String[]::new), getMvnExecEnvs());
283+
284+
// Read the dependency tree output and build a lookup map of resolved versions
285+
List<String> lines = Files.readAllLines(tmpFile);
286+
Map<String, String> resolvedVersions = new HashMap<>();
287+
for (String line : lines) {
288+
if (getDepth(line) == 1) {
289+
DependencyAggregator resolved = parseDep(line);
290+
resolvedVersions.put(resolved.groupId + ":" + resolved.artifactId, resolved.version);
291+
}
292+
}
293+
294+
// Replace version ranges with resolved concrete versions
295+
for (DependencyAggregator dep : deps) {
296+
if (isVersionRange(dep.version)) {
297+
String key = dep.groupId + ":" + dep.artifactId;
298+
String resolved = resolvedVersions.get(key);
299+
if (resolved != null) {
300+
if (debugLoggingIsNeeded()) {
301+
log.info(
302+
String.format(
303+
"Resolved version range for %s: %s -> %s", key, dep.version, resolved));
304+
}
305+
dep.version = resolved;
306+
}
307+
}
308+
}
309+
} catch (Exception e) {
310+
log.log(
311+
Level.WARNING,
312+
String.format(
313+
"Failed to resolve version ranges via dependency tree, "
314+
+ "using original versions: %s",
315+
e.getMessage()),
316+
e);
317+
return deps;
318+
} finally {
319+
try {
320+
Files.deleteIfExists(tmpFile);
321+
} catch (IOException ignored) {
322+
}
323+
}
324+
325+
return deps;
326+
}
327+
242328
private PackageURL getRoot(final Path manifestPath) throws IOException {
243329
XMLStreamReader reader = null;
244330
try {

src/test/java/io/github/guacsec/trustifyda/providers/Java_Maven_Provider_Test.java

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.util.Arrays;
3131
import java.util.Optional;
3232
import java.util.stream.Stream;
33+
import org.junit.jupiter.api.Test;
3334
import org.junit.jupiter.api.extension.ExtendWith;
3435
import org.junit.jupiter.params.ParameterizedTest;
3536
import org.junit.jupiter.params.provider.MethodSource;
@@ -56,7 +57,8 @@ static Stream<String> testFolders() {
5657
"deps_with_ignore_on_version",
5758
"deps_with_ignore_on_wrong",
5859
"deps_with_no_ignore",
59-
"pom_deps_with_no_ignore_common_paths");
60+
"pom_deps_with_no_ignore_common_paths",
61+
"deps_with_version_range");
6062
}
6163

6264
@ParameterizedTest
@@ -143,12 +145,29 @@ void test_the_provideComponent(String testFolder) throws IOException {
143145
getClass(), String.format("tst_manifests/maven/%s/effectivePom.xml", testFolder))) {
144146
effectivePom = new String(is.readAllBytes());
145147
}
148+
149+
String depTree;
150+
try (var is =
151+
getResourceAsStreamDecision(
152+
getClass(), String.format("tst_manifests/maven/%s/depTree.txt", testFolder))) {
153+
depTree = new String(is.readAllBytes());
154+
}
155+
146156
try (MockedStatic<Operations> mockedOperations = mockStatic(Operations.class)) {
147157
mockedOperations
148158
.when(() -> Operations.runProcess(any(), any(), any()))
149159
.thenAnswer(
150-
invocationOnMock ->
151-
getOutputFileAndOverwriteItWithMock(effectivePom, invocationOnMock, "-Doutput"));
160+
invocationOnMock -> {
161+
String result =
162+
getOutputFileAndOverwriteItWithMock(
163+
effectivePom, invocationOnMock, "-Doutput=");
164+
if (result == null) {
165+
result =
166+
getOutputFileAndOverwriteItWithMock(
167+
depTree, invocationOnMock, "-DoutputFile");
168+
}
169+
return result;
170+
});
152171
// Mock Operations.getCustomPathOrElse to return "mvn"
153172
mockedOperations.when(() -> Operations.getCustomPathOrElse(anyString())).thenReturn("mvn");
154173
mockedOperations
@@ -189,12 +208,29 @@ void test_the_provideComponent_With_Path(String testFolder) throws IOException {
189208
getClass(), String.format("tst_manifests/maven/%s/effectivePom.xml", testFolder))) {
190209
effectivePom = new String(is.readAllBytes());
191210
}
211+
212+
String depTree;
213+
try (var is =
214+
getResourceAsStreamDecision(
215+
getClass(), String.format("tst_manifests/maven/%s/depTree.txt", testFolder))) {
216+
depTree = new String(is.readAllBytes());
217+
}
218+
192219
try (MockedStatic<Operations> mockedOperations = mockStatic(Operations.class)) {
193220
mockedOperations
194221
.when(() -> Operations.runProcess(any(), any(), any()))
195222
.thenAnswer(
196-
invocationOnMock ->
197-
getOutputFileAndOverwriteItWithMock(effectivePom, invocationOnMock, "-Doutput"));
223+
invocationOnMock -> {
224+
String result =
225+
getOutputFileAndOverwriteItWithMock(
226+
effectivePom, invocationOnMock, "-Doutput=");
227+
if (result == null) {
228+
result =
229+
getOutputFileAndOverwriteItWithMock(
230+
depTree, invocationOnMock, "-DoutputFile");
231+
}
232+
return result;
233+
});
198234
// Mock Operations.getCustomPathOrElse to return "mvn"
199235
mockedOperations.when(() -> Operations.getCustomPathOrElse(anyString())).thenReturn("mvn");
200236
mockedOperations
@@ -209,6 +245,48 @@ void test_the_provideComponent_With_Path(String testFolder) throws IOException {
209245
}
210246
}
211247

248+
@Test
249+
void test_provideComponent_fallsBack_when_depTree_fails() throws IOException {
250+
String testFolder = "deps_with_version_range";
251+
252+
var targetPom = resolveFile(String.format("tst_manifests/maven/%s/pom.xml", testFolder));
253+
254+
String effectivePom;
255+
try (var is =
256+
getResourceAsStreamDecision(
257+
getClass(), String.format("tst_manifests/maven/%s/effectivePom.xml", testFolder))) {
258+
effectivePom = new String(is.readAllBytes());
259+
}
260+
261+
try (MockedStatic<Operations> mockedOperations = mockStatic(Operations.class)) {
262+
mockedOperations
263+
.when(() -> Operations.runProcess(any(), any(), any()))
264+
.thenAnswer(
265+
invocationOnMock -> {
266+
String result =
267+
getOutputFileAndOverwriteItWithMock(
268+
effectivePom, invocationOnMock, "-Doutput=");
269+
if (result == null) {
270+
throw new IOException("Simulated dependency:tree failure");
271+
}
272+
return result;
273+
});
274+
mockedOperations.when(() -> Operations.getCustomPathOrElse(anyString())).thenReturn("mvn");
275+
mockedOperations
276+
.when(() -> Operations.getExecutable(anyString(), anyString()))
277+
.thenReturn("mvn");
278+
279+
var content = new JavaMavenProvider(targetPom).provideComponent();
280+
281+
assertThat(content.type).isEqualTo(Api.CYCLONEDX_MEDIA_TYPE);
282+
String sbom = new String(content.buffer);
283+
assertThat(sbom).contains("log4j");
284+
assertThat(sbom).contains("snappy-java");
285+
// Version range should remain unresolved since dep tree failed
286+
assertThat(sbom).contains("[1.2.17,1.3.0)");
287+
}
288+
}
289+
212290
private String dropIgnored(String s) {
213291
return s.replaceAll("\\s+", "").replaceAll("\"timestamp\":\"[a-zA-Z0-9\\-\\:]+\",", "");
214292
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
pom-with-deps-version-range:pom-with-version-range-for-tests:jar:0.0.1
2+
+- log4j:log4j:jar:1.2.17:compile
3+
\- org.xerial.snappy:snappy-java:jar:1.1.10.0:compile

0 commit comments

Comments
 (0)