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 .golang .model .GoWorkspace ;
3435import io .github .guacsec .trustifyda .providers .javascript .workspace .JsWorkspaceDiscovery ;
3536import io .github .guacsec .trustifyda .providers .rust .model .CargoMetadata ;
@@ -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,15 @@ 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+ List <Path > mavenManifests = discoverMavenModules (workspaceDir , ignorePatterns );
893+ if (!mavenManifests .isEmpty ()) {
894+ return mavenManifests ;
895+ }
896+ }
897+
887898 // JS workspace: require package.json + a lock file
888899 Path packageJson = workspaceDir .resolve ("package.json" );
889900 boolean hasJsLock =
@@ -976,6 +987,128 @@ private List<Path> discoverGoWorkspaceModules(Path workspaceDir, Set<String> ign
976987 }
977988 }
978989
990+ /**
991+ * Discovers Maven multi-module workspace manifests by invoking {@code mvn help:evaluate} to list
992+ * declared modules, then recursively checking each module for nested aggregators.
993+ *
994+ * <p>Uses {@link JavaMavenProvider#resolveVerifiedMavenBinary} for Maven binary resolution,
995+ * including wrapper support. Always includes the root {@code pom.xml}. Uses a visited set to
996+ * prevent cycles in the module graph.
997+ *
998+ * @param workspaceDir the root directory containing the aggregator pom.xml
999+ * @param ignorePatterns glob patterns for paths to exclude from results
1000+ * @return list of discovered pom.xml paths; always includes the root pom.xml even if Maven is
1001+ * unavailable
1002+ */
1003+ private List <Path > discoverMavenModules (Path workspaceDir , Set <String > ignorePatterns ) {
1004+ Path rootPom = workspaceDir .resolve ("pom.xml" );
1005+ String mvnBin = JavaMavenProvider .resolveVerifiedMavenBinary (workspaceDir ).orElse (null );
1006+ if (mvnBin == null ) {
1007+ LOG .warning ("Maven binary not available; returning root pom.xml only" );
1008+ return List .of (rootPom );
1009+ }
1010+
1011+ var visited = new java .util .HashSet <Path >();
1012+ var manifestPaths = new ArrayList <Path >();
1013+ manifestPaths .add (rootPom );
1014+
1015+ collectMavenModules (workspaceDir , mvnBin , visited , manifestPaths );
1016+
1017+ return WorkspaceUtils .filterByIgnorePatterns (workspaceDir , manifestPaths , ignorePatterns );
1018+ }
1019+
1020+ /**
1021+ * Recursively collects Maven module pom.xml paths by invoking {@code mvn help:evaluate} on each
1022+ * directory and descending into sub-modules.
1023+ *
1024+ * @param dir the directory to evaluate for modules
1025+ * @param mvnBin the resolved Maven binary path
1026+ * @param visited set of already-visited directories to prevent cycles
1027+ * @param manifestPaths accumulator list for discovered pom.xml paths
1028+ */
1029+ private void collectMavenModules (
1030+ Path dir , String mvnBin , java .util .HashSet <Path > visited , List <Path > manifestPaths ) {
1031+ Path resolvedDir = dir .toAbsolutePath ().normalize ();
1032+ if (!visited .add (resolvedDir )) {
1033+ return ;
1034+ }
1035+
1036+ List <String > modules = listMavenModules (resolvedDir , mvnBin );
1037+ for (String mod : modules ) {
1038+ Path moduleDir = resolvedDir .resolve (mod );
1039+ Path modulePom = moduleDir .resolve ("pom.xml" );
1040+ if (Files .isRegularFile (modulePom )) {
1041+ manifestPaths .add (modulePom );
1042+ collectMavenModules (moduleDir , mvnBin , visited , manifestPaths );
1043+ }
1044+ }
1045+ }
1046+
1047+ /**
1048+ * Invokes {@code mvn help:evaluate -Dexpression=project.modules} on the given directory and
1049+ * parses the output to extract module names.
1050+ *
1051+ * @param dir the directory containing a pom.xml to evaluate
1052+ * @param mvnBin the resolved Maven binary path
1053+ * @return list of module names, or empty list on failure
1054+ */
1055+ private List <String > listMavenModules (Path dir , String mvnBin ) {
1056+ try {
1057+ Path pomFile = dir .resolve ("pom.xml" );
1058+ Operations .ProcessExecOutput output =
1059+ Operations .runProcessGetFullOutput (
1060+ dir ,
1061+ new String [] {
1062+ mvnBin ,
1063+ "help:evaluate" ,
1064+ "-Dexpression=project.modules" ,
1065+ "-q" ,
1066+ "-DforceStdout" ,
1067+ "-f" ,
1068+ pomFile .toString (),
1069+ "--batch-mode"
1070+ },
1071+ null );
1072+ if (output .getExitCode () != 0 ) {
1073+ LOG .warning (
1074+ "mvn help:evaluate failed with exit code " + output .getExitCode () + " in " + dir );
1075+ return Collections .emptyList ();
1076+ }
1077+ String raw = output .getOutput ().trim ();
1078+ if (raw .isEmpty () || "null" .equals (raw )) {
1079+ return Collections .emptyList ();
1080+ }
1081+ return parseMavenModuleList (raw );
1082+ } catch (Exception e ) {
1083+ LOG .warning ("Failed to list Maven modules in " + dir + ": " + e .getMessage ());
1084+ return Collections .emptyList ();
1085+ }
1086+ }
1087+
1088+ /**
1089+ * Parses the output of {@code mvn help:evaluate -Dexpression=project.modules} which returns a
1090+ * string like {@code [module-a, module-b]}.
1091+ *
1092+ * @param raw the raw output string
1093+ * @return list of module name strings
1094+ */
1095+ static List <String > parseMavenModuleList (String raw ) {
1096+ if (raw == null || raw .isEmpty ()) {
1097+ return Collections .emptyList ();
1098+ }
1099+ // Expected format: [module-a, module-b, ...]
1100+ java .util .regex .Matcher matcher =
1101+ java .util .regex .Pattern .compile ("^\\ [(.+)]$" ).matcher (raw .trim ());
1102+ if (!matcher .matches ()) {
1103+ return Collections .emptyList ();
1104+ }
1105+ String inner = matcher .group (1 );
1106+ return java .util .Arrays .stream (inner .split ("," ))
1107+ .map (String ::trim )
1108+ .filter (s -> !s .isEmpty ())
1109+ .toList ();
1110+ }
1111+
9791112 /**
9801113 * Checks whether a package.json has "private": true, meaning it should not be analyzed as a
9811114 * publishable package.
0 commit comments