3030import io .github .guacsec .trustifyda .image .ImageUtils ;
3131import io .github .guacsec .trustifyda .license .LicenseCheck ;
3232import io .github .guacsec .trustifyda .logging .LoggersFactory ;
33+ import io .github .guacsec .trustifyda .providers .JavaMavenProvider ;
3334import io .github .guacsec .trustifyda .providers .javascript .workspace .JsWorkspaceDiscovery ;
3435import io .github .guacsec .trustifyda .providers .rust .model .CargoMetadata ;
3536import io .github .guacsec .trustifyda .tools .Ecosystem ;
@@ -842,7 +843,7 @@ int resolveBatchConcurrency() {
842843 }
843844
844845 private static final Set <String > DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
845- Set .of ("**/node_modules/**" , "**/.git/**" );
846+ Set .of ("**/node_modules/**" , "**/.git/**" , "**/target/**" );
846847
847848 /** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
848849 Set <String > resolveIgnorePatterns (Set <String > callerPatterns ) {
@@ -864,7 +865,8 @@ Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
864865
865866 /**
866867 * Detects the workspace ecosystem and discovers manifest paths. Checks for Cargo workspace first
867- * (Cargo.toml + Cargo.lock), then falls back to JS workspace discovery.
868+ * (Cargo.toml + Cargo.lock), then Maven multi-module (pom.xml), then falls back to JS workspace
869+ * discovery.
868870 */
869871 List <Path > discoverWorkspaceManifests (Path workspaceDir , Set <String > ignorePatterns )
870872 throws IOException {
@@ -875,6 +877,12 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
875877 return discoverCargoManifests (workspaceDir , ignorePatterns );
876878 }
877879
880+ // Maven multi-module: pom.xml
881+ Path pomXml = workspaceDir .resolve ("pom.xml" );
882+ if (Files .isRegularFile (pomXml )) {
883+ return discoverMavenModules (workspaceDir , ignorePatterns );
884+ }
885+
878886 // JS workspace: require package.json + a lock file
879887 Path packageJson = workspaceDir .resolve ("package.json" );
880888 boolean hasJsLock =
@@ -930,6 +938,154 @@ private List<Path> discoverCargoManifests(Path workspaceDir, Set<String> ignoreP
930938 }
931939 }
932940
941+ /**
942+ * Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
943+ * declared modules, then recursively checking each module for nested aggregators.
944+ *
945+ * <p>Uses the same Maven binary selection as {@link
946+ * io.github.guacsec.trustifyda.providers.JavaMavenProvider}, including wrapper support via {@code
947+ * selectMvnRuntime}. Always includes the root {@code pom.xml}. Uses a visited set to prevent
948+ * cycles in the module graph.
949+ *
950+ * @param workspaceDir the root directory containing the aggregator pom.xml
951+ * @param ignorePatterns glob patterns for paths to exclude from results
952+ * @return list of discovered pom.xml paths, or empty list if Maven is unavailable
953+ */
954+ private List <Path > discoverMavenModules (Path workspaceDir , Set <String > ignorePatterns ) {
955+ Path rootPom = workspaceDir .resolve ("pom.xml" );
956+ String mvnBin = resolveMavenBinary (workspaceDir );
957+ if (mvnBin == null ) {
958+ LOG .warning ("Maven binary not available; returning root pom.xml only" );
959+ return List .of (rootPom );
960+ }
961+
962+ var visited = new java .util .HashSet <Path >();
963+ var manifestPaths = new ArrayList <Path >();
964+ manifestPaths .add (rootPom );
965+
966+ collectMavenModules (workspaceDir , mvnBin , visited , manifestPaths );
967+
968+ return WorkspaceUtils .filterByIgnorePatterns (workspaceDir , manifestPaths , ignorePatterns );
969+ }
970+
971+ /**
972+ * Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each
973+ * directory and descending into sub-modules.
974+ *
975+ * @param dir the directory to evaluate for modules
976+ * @param mvnBin the resolved Maven binary path
977+ * @param visited set of already-visited directories to prevent cycles
978+ * @param manifestPaths accumulator list for discovered pom.xml paths
979+ */
980+ private void collectMavenModules (
981+ Path dir , String mvnBin , java .util .HashSet <Path > visited , List <Path > manifestPaths ) {
982+ Path resolvedDir = dir .toAbsolutePath ().normalize ();
983+ if (!visited .add (resolvedDir )) {
984+ return ;
985+ }
986+
987+ List <String > modules = listMavenModules (resolvedDir , mvnBin );
988+ for (String mod : modules ) {
989+ Path moduleDir = resolvedDir .resolve (mod );
990+ Path modulePom = moduleDir .resolve ("pom.xml" );
991+ if (Files .isRegularFile (modulePom )) {
992+ manifestPaths .add (modulePom );
993+ collectMavenModules (moduleDir , mvnBin , visited , manifestPaths );
994+ }
995+ }
996+ }
997+
998+ /**
999+ * Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and
1000+ * parses the output to extract module names.
1001+ *
1002+ * @param dir the directory containing a pom.xml to evaluate
1003+ * @param mvnBin the resolved Maven binary path
1004+ * @return list of module names, or empty list on failure
1005+ */
1006+ private List <String > listMavenModules (Path dir , String mvnBin ) {
1007+ try {
1008+ Path pomFile = dir .resolve ("pom.xml" );
1009+ Operations .ProcessExecOutput output =
1010+ Operations .runProcessGetFullOutput (
1011+ dir ,
1012+ new String [] {
1013+ mvnBin ,
1014+ "help:evaluate" ,
1015+ "-Dexpression=project.modules" ,
1016+ "-q" ,
1017+ "-DforceStdout" ,
1018+ "-f" ,
1019+ pomFile .toString (),
1020+ "--batch-mode"
1021+ },
1022+ null );
1023+ if (output .getExitCode () != 0 ) {
1024+ LOG .warning (
1025+ "mvn help:evaluate failed with exit code " + output .getExitCode () + " in " + dir );
1026+ return Collections .emptyList ();
1027+ }
1028+ String raw = output .getOutput ().trim ();
1029+ if (raw .isEmpty () || "null" .equals (raw )) {
1030+ return Collections .emptyList ();
1031+ }
1032+ return parseMavenModuleList (raw );
1033+ } catch (Exception e ) {
1034+ LOG .warning ("Failed to list Maven modules in " + dir + ": " + e .getMessage ());
1035+ return Collections .emptyList ();
1036+ }
1037+ }
1038+
1039+ /**
1040+ * Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a
1041+ * string like {@code [module-a, module-b]}.
1042+ *
1043+ * @param raw the raw output string
1044+ * @return list of module name strings
1045+ */
1046+ static List <String > parseMavenModuleList (String raw ) {
1047+ if (raw == null || raw .isEmpty ()) {
1048+ return Collections .emptyList ();
1049+ }
1050+ // Expected format: [module-a, module-b, ...]
1051+ java .util .regex .Matcher matcher =
1052+ java .util .regex .Pattern .compile ("^\\ [(.+)]$" ).matcher (raw .trim ());
1053+ if (!matcher .matches ()) {
1054+ return Collections .emptyList ();
1055+ }
1056+ String inner = matcher .group (1 );
1057+ return java .util .Arrays .stream (inner .split ("," ))
1058+ .map (String ::trim )
1059+ .filter (s -> !s .isEmpty ())
1060+ .toList ();
1061+ }
1062+
1063+ /**
1064+ * Resolves the Maven binary to use, following the same wrapper preference logic as {@link
1065+ * io.github.guacsec.trustifyda.providers.JavaMavenProvider#selectMvnRuntime}.
1066+ *
1067+ * @param startDir the directory from which to start searching for mvnw
1068+ * @return the resolved Maven binary path, or null if Maven is not available
1069+ */
1070+ private String resolveMavenBinary (Path startDir ) {
1071+ try {
1072+ boolean preferWrapper = Operations .getWrapperPreference ("mvn" );
1073+ String mvn = Operations .isWindows () ? "mvn.cmd" : "mvn" ;
1074+ if (preferWrapper ) {
1075+ String wrapperName = Operations .isWindows () ? "mvnw.cmd" : "mvnw" ;
1076+ String mvnw =
1077+ JavaMavenProvider .traverseForMvnw (wrapperName , startDir .resolve ("pom.xml" ).toString ());
1078+ if (mvnw != null ) {
1079+ return mvnw ;
1080+ }
1081+ }
1082+ return Operations .getCustomPathOrElse (mvn );
1083+ } catch (Exception e ) {
1084+ LOG .warning ("Failed to resolve Maven binary: " + e .getMessage ());
1085+ return null ;
1086+ }
1087+ }
1088+
9331089 /**
9341090 * Checks whether a package.json has "private": true, meaning it should not be analyzed as a
9351091 * publishable package.
0 commit comments