3131import io .github .guacsec .trustifyda .license .LicenseCheck ;
3232import io .github .guacsec .trustifyda .logging .LoggersFactory ;
3333import io .github .guacsec .trustifyda .providers .golang .model .GoWorkspace ;
34+ import io .github .guacsec .trustifyda .providers .JavaMavenProvider ;
3435import io .github .guacsec .trustifyda .providers .javascript .workspace .JsWorkspaceDiscovery ;
3536import io .github .guacsec .trustifyda .providers .rust .model .CargoMetadata ;
3637import io .github .guacsec .trustifyda .tools .Ecosystem ;
@@ -843,7 +844,7 @@ int resolveBatchConcurrency() {
843844 }
844845
845846 private static final Set <String > DEFAULT_WORKSPACE_DISCOVERY_IGNORE =
846- Set .of ("**/node_modules/**" , "**/.git/**" );
847+ Set .of ("**/node_modules/**" , "**/.git/**" , "**/target/**" );
847848
848849 /** Merges default ignore patterns, env var overrides, and caller-provided patterns. */
849850 Set <String > resolveIgnorePatterns (Set <String > callerPatterns ) {
@@ -865,7 +866,8 @@ Set<String> resolveIgnorePatterns(Set<String> callerPatterns) {
865866
866867 /**
867868 * Detects the workspace ecosystem and discovers manifest paths. Checks for Cargo workspace first
868- * (Cargo.toml + Cargo.lock), then falls back to JS workspace discovery.
869+ * (Cargo.toml + Cargo.lock), then Maven multi-module (pom.xml), then falls back to JS workspace
870+ * discovery.
869871 */
870872 List <Path > discoverWorkspaceManifests (Path workspaceDir , Set <String > ignorePatterns )
871873 throws IOException {
@@ -884,6 +886,12 @@ List<Path> discoverWorkspaceManifests(Path workspaceDir, Set<String> ignorePatte
884886 }
885887 }
886888
889+ // Maven multi-module: pom.xml
890+ Path pomXml = workspaceDir .resolve ("pom.xml" );
891+ if (Files .isRegularFile (pomXml )) {
892+ return discoverMavenModules (workspaceDir , ignorePatterns );
893+ }
894+
887895 // JS workspace: require package.json + a lock file
888896 Path packageJson = workspaceDir .resolve ("package.json" );
889897 boolean hasJsLock =
@@ -975,6 +983,153 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
975983 return Collections .emptyList ();
976984 }
977985 }
986+ /**
987+ * Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
988+ * declared modules, then recursively checking each module for nested aggregators.
989+ *
990+ * <p>Uses the same Maven binary selection as {@link
991+ * io.github.guacsec.trustifyda.providers.JavaMavenProvider}, including wrapper support via {@code
992+ * selectMvnRuntime}. Always includes the root {@code pom.xml}. Uses a visited set to prevent
993+ * cycles in the module graph.
994+ *
995+ * @param workspaceDir the root directory containing the aggregator pom.xml
996+ * @param ignorePatterns glob patterns for paths to exclude from results
997+ * @return list of discovered pom.xml paths, or empty list if Maven is unavailable
998+ */
999+ private List <Path > discoverMavenModules (Path workspaceDir , Set <String > ignorePatterns ) {
1000+ Path rootPom = workspaceDir .resolve ("pom.xml" );
1001+ String mvnBin = resolveMavenBinary (workspaceDir );
1002+ if (mvnBin == null ) {
1003+ LOG .warning ("Maven binary not available; returning root pom.xml only" );
1004+ return List .of (rootPom );
1005+ }
1006+
1007+ var visited = new java .util .HashSet <Path >();
1008+ var manifestPaths = new ArrayList <Path >();
1009+ manifestPaths .add (rootPom );
1010+
1011+ collectMavenModules (workspaceDir , mvnBin , visited , manifestPaths );
1012+
1013+ return WorkspaceUtils .filterByIgnorePatterns (workspaceDir , manifestPaths , ignorePatterns );
1014+ }
1015+
1016+ /**
1017+ * Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each
1018+ * directory and descending into sub-modules.
1019+ *
1020+ * @param dir the directory to evaluate for modules
1021+ * @param mvnBin the resolved Maven binary path
1022+ * @param visited set of already-visited directories to prevent cycles
1023+ * @param manifestPaths accumulator list for discovered pom.xml paths
1024+ */
1025+ private void collectMavenModules (
1026+ Path dir , String mvnBin , java .util .HashSet <Path > visited , List <Path > manifestPaths ) {
1027+ Path resolvedDir = dir .toAbsolutePath ().normalize ();
1028+ if (!visited .add (resolvedDir )) {
1029+ return ;
1030+ }
1031+
1032+ List <String > modules = listMavenModules (resolvedDir , mvnBin );
1033+ for (String mod : modules ) {
1034+ Path moduleDir = resolvedDir .resolve (mod );
1035+ Path modulePom = moduleDir .resolve ("pom.xml" );
1036+ if (Files .isRegularFile (modulePom )) {
1037+ manifestPaths .add (modulePom );
1038+ collectMavenModules (moduleDir , mvnBin , visited , manifestPaths );
1039+ }
1040+ }
1041+ }
1042+
1043+ /**
1044+ * Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and
1045+ * parses the output to extract module names.
1046+ *
1047+ * @param dir the directory containing a pom.xml to evaluate
1048+ * @param mvnBin the resolved Maven binary path
1049+ * @return list of module names, or empty list on failure
1050+ */
1051+ private List <String > listMavenModules (Path dir , String mvnBin ) {
1052+ try {
1053+ Path pomFile = dir .resolve ("pom.xml" );
1054+ Operations .ProcessExecOutput output =
1055+ Operations .runProcessGetFullOutput (
1056+ dir ,
1057+ new String [] {
1058+ mvnBin ,
1059+ "help:evaluate" ,
1060+ "-Dexpression=project.modules" ,
1061+ "-q" ,
1062+ "-DforceStdout" ,
1063+ "-f" ,
1064+ pomFile .toString (),
1065+ "--batch-mode"
1066+ },
1067+ null );
1068+ if (output .getExitCode () != 0 ) {
1069+ LOG .warning (
1070+ "mvn help:evaluate failed with exit code " + output .getExitCode () + " in " + dir );
1071+ return Collections .emptyList ();
1072+ }
1073+ String raw = output .getOutput ().trim ();
1074+ if (raw .isEmpty () || "null" .equals (raw )) {
1075+ return Collections .emptyList ();
1076+ }
1077+ return parseMavenModuleList (raw );
1078+ } catch (Exception e ) {
1079+ LOG .warning ("Failed to list Maven modules in " + dir + ": " + e .getMessage ());
1080+ return Collections .emptyList ();
1081+ }
1082+ }
1083+
1084+ /**
1085+ * Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a
1086+ * string like {@code [module-a, module-b]}.
1087+ *
1088+ * @param raw the raw output string
1089+ * @return list of module name strings
1090+ */
1091+ static List <String > parseMavenModuleList (String raw ) {
1092+ if (raw == null || raw .isEmpty ()) {
1093+ return Collections .emptyList ();
1094+ }
1095+ // Expected format: [module-a, module-b, ...]
1096+ java .util .regex .Matcher matcher =
1097+ java .util .regex .Pattern .compile ("^\\ [(.+)]$" ).matcher (raw .trim ());
1098+ if (!matcher .matches ()) {
1099+ return Collections .emptyList ();
1100+ }
1101+ String inner = matcher .group (1 );
1102+ return java .util .Arrays .stream (inner .split ("," ))
1103+ .map (String ::trim )
1104+ .filter (s -> !s .isEmpty ())
1105+ .toList ();
1106+ }
1107+
1108+ /**
1109+ * Resolves the Maven binary to use, following the same wrapper preference logic as {@link
1110+ * io.github.guacsec.trustifyda.providers.JavaMavenProvider#selectMvnRuntime}.
1111+ *
1112+ * @param startDir the directory from which to start searching for mvnw
1113+ * @return the resolved Maven binary path, or null if Maven is not available
1114+ */
1115+ private String resolveMavenBinary (Path startDir ) {
1116+ try {
1117+ boolean preferWrapper = Operations .getWrapperPreference ("mvn" );
1118+ String mvn = Operations .isWindows () ? "mvn.cmd" : "mvn" ;
1119+ if (preferWrapper ) {
1120+ String wrapperName = Operations .isWindows () ? "mvnw.cmd" : "mvnw" ;
1121+ String mvnw =
1122+ JavaMavenProvider .traverseForMvnw (wrapperName , startDir .resolve ("pom.xml" ).toString ());
1123+ if (mvnw != null ) {
1124+ return mvnw ;
1125+ }
1126+ }
1127+ return Operations .getCustomPathOrElse (mvn );
1128+ } catch (Exception e ) {
1129+ LOG .warning ("Failed to resolve Maven binary: " + e .getMessage ());
1130+ return null ;
1131+ }
1132+ }
9781133
9791134 /**
9801135 * Checks whether a package.json has "private": true, meaning it should not be analyzed as a
0 commit comments