Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4d67d6c
Build Gradle impact paths per module
Jordanh1996 Sep 22, 2026
b5175bf
Add the Gradle multi-module impact-path test fixture
Jordanh1996 Sep 22, 2026
d8ce1e3
Guarantee an impact tree for every vulnerable dependency
Jordanh1996 Sep 22, 2026
d9ba43f
Address final review of the per-module impact paths
Jordanh1996 Sep 22, 2026
450261a
Drop the fallback log line
Jordanh1996 Sep 22, 2026
ab70b0d
Make the module impact-path unit tests cover the per-module walk
Jordanh1996 Sep 22, 2026
5d405d5
Use ide-plugins-common 2.5.0
Jordanh1996 Oct 1, 2026
df39064
Put the plugin's Jackson ahead of the IDE's on the test classpath
Jordanh1996 Oct 1, 2026
0ac1238
Reorder the test classpath at configuration time
Jordanh1996 Oct 1, 2026
a38518e
Trim doc comments to what each declaration does
Jordanh1996 Oct 4, 2026
32ee34f
Build the impact trees in ImpactTreeBuilder
Jordanh1996 Oct 4, 2026
53eb4d0
Remove the test classpath reorder and the unneeded Jackson bump
Jordanh1996 Oct 4, 2026
47493ee
Drop the module-membership check, which getOrDefault already covers
Jordanh1996 Oct 4, 2026
c7e4fa3
Document the direct-path fallback
Jordanh1996 Oct 4, 2026
accc8e8
Say why the direct-path fallback exists
Jordanh1996 Oct 4, 2026
f0c19b5
Name the crash the direct-path fallback prevents
Jordanh1996 Oct 4, 2026
084059e
Call the direct path a fallback
Jordanh1996 Oct 4, 2026
f0291ca
Load the plugin's Jackson ahead of the IDE's in tests
Jordanh1996 Oct 4, 2026
05f0072
Declare the Jackson version the plugin already resolves
Jordanh1996 Oct 4, 2026
9610190
Apply the test Jackson ordering to every test task
Jordanh1996 Oct 4, 2026
4cfd8db
Assert the full impact path through moda in the Gradle module test
Jordanh1996 Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ repositories {
}

def buildInfoVersion = '2.43.9'
def idePluginsCommonVersion = '2.4.5'
def jacksonVersion = '2.18.6'
def idePluginsCommonVersion = '2.5.0'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
def jacksonVersion = '2.21.7'

dependencies {
implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-yaml', version: jacksonVersion
Expand All @@ -99,6 +99,12 @@ dependencies {
testImplementation group: 'org.mockito', name: 'mockito-core', version: '4.2.0'
}

tasks.withType(Test).configureEach {
// The IDE's app.jar bundles an older Jackson; tests load the plugin's Jackson first, as the IDE does.
def jacksonJars = classpath.filter { it.name.startsWith('jackson-') }
classpath = jacksonJars + (classpath - jacksonJars)
}

test {
scanForTestClasses false
include "**/*Test.class"
Expand Down
13 changes: 2 additions & 11 deletions src/main/java/com/jfrog/ide/idea/scan/ScannerBase.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import com.intellij.psi.PsiFile;
import com.jfrog.ide.common.configuration.ServerConfig;
import com.jfrog.ide.common.deptree.DepTree;
import com.jfrog.ide.common.deptree.DepTreeNode;
import com.jfrog.ide.common.log.ProgressIndicator;
import com.jfrog.ide.common.nodes.DependencyNode;
import com.jfrog.ide.common.nodes.FileTreeNode;
Expand Down Expand Up @@ -187,7 +186,7 @@ private void scanAndUpdate(ProgressIndicator indicator) {

protected List<FileTreeNode> buildImpactGraph(Map<String, DependencyNode> vulnerableDependencies, DepTree depTree) throws IOException {
Map<String, Set<String>> parents = getParents(depTree);
ImpactTreeBuilder.populateImpactTrees(vulnerableDependencies, parents, depTree.rootId());
ImpactTreeBuilder.populateImpactTrees(vulnerableDependencies, depTree);
return groupDependenciesToDescriptorNodes(vulnerableDependencies.values(), depTree, parents);
}

Expand All @@ -199,15 +198,7 @@ protected List<FileTreeNode> buildImpactGraph(Map<String, DependencyNode> vulner
* @return a map of nodes from the {@link DepTree} amd each one's parents
*/
static Map<String, Set<String>> getParents(DepTree depTree) {
Map<String, Set<String>> parents = new HashMap<>();
for (Map.Entry<String, DepTreeNode> node : depTree.nodes().entrySet()) {
String parentId = node.getKey();
for (String childId : node.getValue().getChildren()) {
parents.putIfAbsent(childId, new HashSet<>());
parents.get(childId).add(parentId);
}
}
return parents;
return ImpactTreeBuilder.getParents(depTree.nodes());
}

/**
Expand Down
96 changes: 84 additions & 12 deletions src/main/java/com/jfrog/ide/idea/scan/utils/ImpactTreeBuilder.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.jfrog.ide.idea.scan.utils;

import com.jfrog.ide.common.deptree.DepTree;
import com.jfrog.ide.common.deptree.DepTreeModule;
import com.jfrog.ide.common.deptree.DepTreeNode;
import com.jfrog.ide.common.nodes.DependencyNode;
import com.jfrog.ide.common.nodes.DescriptorFileTreeNode;
import com.jfrog.ide.common.nodes.FileTreeNode;
Expand All @@ -9,6 +12,42 @@
import java.util.*;

public class ImpactTreeBuilder {
/**
* Builds impact paths for {@link DependencyNode} objects, walking each module's own tree when the project has modules.
*
* @param vulnerableDependencies a map of component IDs and the {@link DependencyNode} object matching each of them
* @param depTree the project's dependency tree
*/
public static void populateImpactTrees(Map<String, DependencyNode> vulnerableDependencies, DepTree depTree) {
if (depTree.modules().isEmpty()) {
populateImpactTrees(vulnerableDependencies, getParents(depTree.nodes()), depTree.rootId());
} else {
for (DepTreeModule module : depTree.modules()) {
String projectRootId = module.rootId().equals(depTree.rootId()) ? null : depTree.rootId();
populateImpactTrees(vulnerableDependencies, getParents(module.nodes()), module.rootId(), projectRootId);
}
}
addMissingImpactTrees(vulnerableDependencies, depTree.rootId());
}

/**
* Find the parents of each node. Nodes without parents (the root) don't appear in the returned map.
*
* @param nodes a map of component IDs and their {@link DepTreeNode}
* @return a map of the nodes and each one's parents
*/
public static Map<String, Set<String>> getParents(Map<String, DepTreeNode> nodes) {
Map<String, Set<String>> parents = new HashMap<>();
for (Map.Entry<String, DepTreeNode> node : nodes.entrySet()) {
String parentId = node.getKey();
for (String childId : node.getValue().getChildren()) {
parents.putIfAbsent(childId, new HashSet<>());
parents.get(childId).add(parentId);
}
}
return parents;
}

/**
* Builds impact paths for {@link DependencyNode} objects.
*
Expand All @@ -18,8 +57,20 @@ public class ImpactTreeBuilder {
* @param rootId the project's root component ID
*/
public static void populateImpactTrees(Map<String, DependencyNode> vulnerableDependencies, Map<String, Set<String>> parents, String rootId) {
populateImpactTrees(vulnerableDependencies, parents, rootId, null);
}

/**
* Builds impact paths for {@link DependencyNode} objects within a single module of the project.
*
* @param vulnerableDependencies a map of component IDs and the {@link DependencyNode} object matching each of them
* @param parents a map of the module's dependencies and their parents
* @param rootId the module's root component ID, where every impact path ends
* @param projectRootId the project's root component ID to prepend to each path, or null
*/
private static void populateImpactTrees(Map<String, DependencyNode> vulnerableDependencies, Map<String, Set<String>> parents, String rootId, String projectRootId) {
for (DependencyNode vulnDep : vulnerableDependencies.values()) {
walkParents(vulnDep, parents, rootId, Collections.singletonList(vulnDep.getComponentIdWithoutPrefix()));
walkParents(vulnDep, parents, rootId, Collections.singletonList(vulnDep.getComponentIdWithoutPrefix()), projectRootId);
}
}

Expand All @@ -28,29 +79,50 @@ public static void populateImpactTrees(Map<String, DependencyNode> vulnerableDep
*
* @param depNode a vulnerable dependency
* @param parents a map of all dependencies and their parents
* @param rootId the project's root component ID
* @param rootId the module's root component ID
* @param path a path of nodes (represented by their component IDs) from the current parent to the current node
* @param projectRootId the project's root component ID to prepend to each path, or null
*/
private static void walkParents(DependencyNode depNode, Map<String, Set<String>> parents, String rootId, List<String> path) {
private static void walkParents(DependencyNode depNode, Map<String, Set<String>> parents, String rootId, List<String> path, String projectRootId) {
String currParentId = path.get(0);
if (depNode.getImpactTree() != null && depNode.getImpactTree().getImpactPathsCount() >= ImpactTree.IMPACT_PATHS_LIMIT) {
return;
}
// If we arrived at the root, add the path to the impact tree
if (currParentId.equals(rootId)) {
addImpactPathToDependencyNode(depNode, path);
} else {
for (String grandparentId : parents.get(currParentId)) {
if (path.contains(grandparentId)) {
continue;
}
List<String> pathToGrandparent = new ArrayList<>(path);
pathToGrandparent.add(0, grandparentId);
walkParents(depNode, parents, rootId, pathToGrandparent);
addImpactPathToDependencyNode(depNode, prependProjectRoot(path, projectRootId));
return;
}
for (String grandparentId : parents.getOrDefault(currParentId, Collections.emptySet())) {
if (path.contains(grandparentId)) {
continue;
}
List<String> pathToGrandparent = new ArrayList<>(path);
pathToGrandparent.add(0, grandparentId);
walkParents(depNode, parents, rootId, pathToGrandparent, projectRootId);
}
}

/**
* Gives unreachable libraries a fallback direct path, since code reading impact trees crashes on a missing one.
*/
private static void addMissingImpactTrees(Map<String, DependencyNode> vulnerableDependencies, String rootId) {
for (DependencyNode dependency : vulnerableDependencies.values()) {
if (dependency.getImpactTree() == null) {
addImpactPathToDependencyNode(dependency, List.of(rootId, dependency.getComponentIdWithoutPrefix()));
}
}
}

private static List<String> prependProjectRoot(List<String> path, String projectRootId) {
if (projectRootId == null) {
return path;
}
List<String> fullPath = new ArrayList<>(path);
fullPath.add(0, projectRootId);
return fullPath;
}

public static void addImpactPathToDependencyNode(DependencyNode dependencyNode, List<String> path) {
if (dependencyNode.getImpactTree() == null) {
dependencyNode.setImpactTree(new ImpactTree(new ImpactTreeNode(path.get(0))));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.jfrog.ide.idea.scan;

import com.jfrog.ide.idea.scan.utils.ImpactTreeBuilder;

import com.jfrog.ide.common.deptree.DepTree;
import com.jfrog.ide.common.gradle.GradleTreeBuilder;
import com.jfrog.ide.common.nodes.DependencyNode;
import com.jfrog.ide.common.nodes.subentities.ImpactTreeNode;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.jfrog.build.api.util.NullLog;
import org.junit.Assert;
import org.junit.Test;

import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* A Gradle project where 'moda' gets commons-lang3 through commons-text and 'modb' excludes it.
*/
public class GradleModuleImpactPathsTest {
private static final String EXCLUDED_BY_MODB_COMP_ID = "org.apache.commons:commons-lang3:3.11";
private static final String PATH_THROUGH_MODA = "com.example:moda:1.0 -> org.apache.commons:commons-text:1.9 -> " + EXCLUDED_BY_MODB_COMP_ID;
private static final String GRADLE_DEP_TREE_CLASS = "com.jfrog.GradleDependencyNode";

@Test
public void testImpactPathsNameOnlyTheModuleThatResolvesTheDependency() throws Exception {
Path projectDir = Files.createTempDirectory("moduleImpactPaths");
try {
DepTree depTree = buildDependencyTree(projectDir);
Assert.assertTrue("commons-lang3 is expected in the dependency tree", depTree.nodes().containsKey(EXCLUDED_BY_MODB_COMP_ID));

DependencyNode vulnerableDependency = new DependencyNode().componentId("gav://" + EXCLUDED_BY_MODB_COMP_ID);
ImpactTreeBuilder.populateImpactTrees(Map.of(EXCLUDED_BY_MODB_COMP_ID, vulnerableDependency), depTree);

Assert.assertNotNull("an impact path should have been built for commons-lang3", vulnerableDependency.getImpactTree());
List<String> paths = new ArrayList<>();
collectPaths(vulnerableDependency.getImpactTree().getRoot(), "", paths);
Assert.assertEquals("commons-lang3 is only reachable through moda: " + paths, 1, paths.size());
Comment thread
Jordanh1996 marked this conversation as resolved.
Assert.assertTrue("the only impact path must go through moda and commons-text: " + paths, paths.get(0).endsWith(PATH_THROUGH_MODA));
Assert.assertFalse("modb excludes commons-lang3: " + paths, paths.get(0).contains(":modb:"));
} finally {
FileUtils.deleteQuietly(projectDir.toFile());
}
}

private void collectPaths(ImpactTreeNode node, String prefix, List<String> paths) {
String path = prefix.isEmpty() ? node.getName() : prefix + " -> " + node.getName();
if (node.getChildren().isEmpty()) {
paths.add(path);
return;
}
node.getChildren().forEach(child -> collectPaths(child, path, paths));
}

private DepTree buildDependencyTree(Path projectDir) throws Exception {
Path source = Paths.get("src", "test", "resources", "gradle", "moduleImpactPaths").toAbsolutePath().normalize();
FileUtils.copyDirectory(source.toFile(), projectDir.toFile());
Map<String, String> env = new HashMap<>(System.getenv());
env.put("pluginLibDir", copyDepTreePlugin(projectDir).toString());
GradleTreeBuilder treeBuilder = new GradleTreeBuilder(projectDir, projectDir.resolve("build.gradle").toString(), env, "");
return treeBuilder.buildTree(new NullLog());
}

private Path copyDepTreePlugin(Path projectDir) throws Exception {
Path libDir = Files.createDirectories(projectDir.resolve("gradle-dep-tree-lib"));
FileUtils.copyFileToDirectory(gradleDepTreeJar().toFile(), libDir.toFile());
return libDir;
}

private Path gradleDepTreeJar() throws Exception {
String resource = "/" + GRADLE_DEP_TREE_CLASS.replace('.', '/') + ".class";
URL location = Class.forName(GRADLE_DEP_TREE_CLASS).getResource(resource);
Assert.assertTrue("'" + GRADLE_DEP_TREE_CLASS + "' is expected to come from a jar, but was loaded from " + location,
StringUtils.startsWith(location.toString(), "jar:") && StringUtils.contains(location.toString(), "!"));
String jarUrl = StringUtils.substringBefore(StringUtils.removeStart(location.toString(), "jar:"), "!");
return Path.of(URI.create(jarUrl));
}
}
Loading
Loading