diff --git a/cli/docs/flags.go b/cli/docs/flags.go index f3723c3f3..f9e6cd4c9 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -164,6 +164,7 @@ const ( IncludeCachedPackages = "include-cached-packages" LegacyPeerDeps = "legacy-peer-deps" RunNative = "run-native" + MvnIncludePluginDeps = "mvn-include-plugin-deps" // Unique git flags gitPrefix = "git-" @@ -227,7 +228,7 @@ var commandFlags = map[string][]string{ StaticSca, XrayLibPluginBinaryCustomPath, AnalyzerManagerCustomPath, AddSastRules, }, CurationAudit: { - CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, UseIncludedBuilds, SolutionPath, DockerImageName, IncludeCachedPackages, LegacyPeerDeps, RunNative, + CurationOutput, WorkingDirs, Threads, RequirementsFile, InsecureTls, useWrapperAudit, UseIncludedBuilds, SolutionPath, DockerImageName, IncludeCachedPackages, MvnIncludePluginDeps, LegacyPeerDeps, RunNative, }, GitCountContributors: { InputFile, ScmType, ScmApiUrl, Token, Owner, RepoName, Months, DetailedSummary, InsecureTls, GitThreads, CacheValidity, @@ -350,6 +351,7 @@ var flagsMap = map[string]components.Flag{ CurationOutput: components.NewStringFlag(OutputFormat, "Defines the output format of the command. Acceptable values are: table, json.", components.WithStrDefaultValue("table")), SolutionPath: components.NewStringFlag(SolutionPath, "Path to the .NET solution file (.sln) to use when multiple solution files are present in the directory."), IncludeCachedPackages: components.NewBoolFlag(IncludeCachedPackages, "When set to true, the system will audit cached packages. This configuration is mandatory for Curation on-demand workflows, which rely on package caching."), + MvnIncludePluginDeps: components.NewBoolFlag(MvnIncludePluginDeps, "[Maven] When set to true, Maven build-plugin transitive dependencies are included in the curation evaluation. Requires two additional Maven invocations (help:effective-pom, dependency:resolve-plugins) which may slow down the scan. By default only project dependencies are scanned."), LegacyPeerDeps: components.NewBoolFlag(LegacyPeerDeps, "[npm] Pass --legacy-peer-deps to npm install to bypass peer-dependency version conflicts."), RunNative: components.NewBoolFlag(RunNative, "[npm] Use the native npm client for dependency resolution. Reads Artifactory URL and repository from the project's .npmrc registry — no 'jf npm-config' required. Respects .npmrc and Volta configuration."), binarySca: components.NewBoolFlag(Sca, fmt.Sprintf("Selective scanners mode: Execute SCA (Software Composition Analysis) sub-scan. Use --%s to run both SCA and Contextual Analysis. Use --%s --%s to to run SCA. Can be combined with --%s.", Sca, Sca, WithoutCA, Secrets)), diff --git a/cli/scancommands.go b/cli/scancommands.go index ee52404ea..b97e37983 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -739,6 +739,7 @@ func getCurationCommand(c *components.Context) (*curation.CurationAuditCommand, SetSolutionFilePath(c.GetStringFlagValue(flags.SolutionPath)) curationAuditCommand.SetDockerImageName(c.GetStringFlagValue(flags.DockerImageName)) curationAuditCommand.SetIncludeCachedPackages(c.GetBoolFlagValue(flags.IncludeCachedPackages)) + curationAuditCommand.SetMvnIncludePluginDeps(c.GetBoolFlagValue(flags.MvnIncludePluginDeps)) curationAuditCommand.SetLegacyPeerDeps(c.GetBoolFlagValue(flags.LegacyPeerDeps)) curationAuditCommand.SetRunNative(c.GetBoolFlagValue(flags.RunNative)) return curationAuditCommand, nil diff --git a/commands/curation/curationaudit.go b/commands/curation/curationaudit.go index e37ff7cee..d23f50b33 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -233,6 +233,7 @@ type CurationAuditCommand struct { parallelRequests int dockerImageName string includeCachedPackages bool + mvnIncludePluginDeps bool audit.AuditParamsInterface } @@ -283,6 +284,11 @@ func (ca *CurationAuditCommand) SetIncludeCachedPackages(includeCachedPackages b return ca } +func (ca *CurationAuditCommand) SetMvnIncludePluginDeps(mvnIncludePluginDeps bool) *CurationAuditCommand { + ca.mvnIncludePluginDeps = mvnIncludePluginDeps + return ca +} + func (ca *CurationAuditCommand) Run() (err error) { rootDir, err := os.Getwd() if err != nil { @@ -451,7 +457,8 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn Args: ca.Args(), InstallCommandArgs: ca.InstallCommandArgs(), // Curation params - IsCurationCmd: true, + IsCurationCmd: true, + MvnIncludePluginDeps: ca.mvnIncludePluginDeps, // Java params IsMavenDepTreeInstalled: true, UseWrapper: ca.UseWrapper(), diff --git a/sca/bom/buildinfo/buildinfobom.go b/sca/bom/buildinfo/buildinfobom.go index a8d504812..66fed8596 100644 --- a/sca/bom/buildinfo/buildinfobom.go +++ b/sca/bom/buildinfo/buildinfobom.go @@ -263,6 +263,7 @@ func GetTechDependencyTree(params technologies.BuildInfoBomGeneratorParams, arti IsMavenDepTreeInstalled: params.IsMavenDepTreeInstalled, UseWrapper: params.UseWrapper, IsCurationCmd: params.IsCurationCmd, + MvnIncludePluginDeps: params.MvnIncludePluginDeps, CurationCacheFolder: curationCacheFolder, UseIncludedBuilds: params.UseIncludedBuilds, }, tech) diff --git a/sca/bom/buildinfo/technologies/common.go b/sca/bom/buildinfo/technologies/common.go index 6e2b3f002..fcd75d77b 100644 --- a/sca/bom/buildinfo/technologies/common.go +++ b/sca/bom/buildinfo/technologies/common.go @@ -48,7 +48,8 @@ type BuildInfoBomGeneratorParams struct { Args []string InstallCommandArgs []string // Curation params - IsCurationCmd bool + IsCurationCmd bool + MvnIncludePluginDeps bool // Java params IsMavenDepTreeInstalled bool UseWrapper bool diff --git a/sca/bom/buildinfo/technologies/java/deptreemanager.go b/sca/bom/buildinfo/technologies/java/deptreemanager.go index 52fd914e6..53fa62030 100644 --- a/sca/bom/buildinfo/technologies/java/deptreemanager.go +++ b/sca/bom/buildinfo/technologies/java/deptreemanager.go @@ -30,6 +30,7 @@ type DepTreeParams struct { DepsRepo string IsMavenDepTreeInstalled bool IsCurationCmd bool + MvnIncludePluginDeps bool CurationCacheFolder string UseIncludedBuilds bool } diff --git a/sca/bom/buildinfo/technologies/java/mvn.go b/sca/bom/buildinfo/technologies/java/mvn.go index ab6a31a19..ab38d7b25 100644 --- a/sca/bom/buildinfo/technologies/java/mvn.go +++ b/sca/bom/buildinfo/technologies/java/mvn.go @@ -3,6 +3,7 @@ package java import ( "bytes" _ "embed" + "encoding/xml" "errors" "fmt" "net/url" @@ -10,6 +11,7 @@ import ( "os/exec" "path" "path/filepath" + "regexp" "strings" "text/template" @@ -51,8 +53,10 @@ var mavenDepTreeJar []byte type MavenDepTreeManager struct { DepTreeManager isInstalled bool - // this flag its curation command, it will set dedicated cache and download url. + // isCurationCmd sets a dedicated cache and download URL for curation mode. isCurationCmd bool + // mvnIncludePluginDeps enables resolution of Maven build-plugin transitive deps. + mvnIncludePluginDeps bool // path to the curation dedicated cache curationCacheFolder string cmdName MavenDepTreeCmd @@ -62,11 +66,12 @@ type MavenDepTreeManager struct { func NewMavenDepTreeManager(params *DepTreeParams, cmdName MavenDepTreeCmd) *MavenDepTreeManager { depTreeManager := NewDepTreeManager(params) return &MavenDepTreeManager{ - DepTreeManager: depTreeManager, - isInstalled: params.IsMavenDepTreeInstalled, - cmdName: cmdName, - isCurationCmd: params.IsCurationCmd, - curationCacheFolder: params.CurationCacheFolder, + DepTreeManager: depTreeManager, + isInstalled: params.IsMavenDepTreeInstalled, + cmdName: cmdName, + isCurationCmd: params.IsCurationCmd, + mvnIncludePluginDeps: params.MvnIncludePluginDeps, + curationCacheFolder: params.CurationCacheFolder, } } @@ -83,9 +88,344 @@ func buildMavenDependencyTree(params *DepTreeParams) (dependencyTree []*xrayUtil err = errors.Join(err, clearMavenDepTreeRun()) }() dependencyTree, uniqueDeps, err = getGraphFromDepTree(outputFilePaths) + if err != nil { + return + } + // Include Maven build-plugin transitive deps when requested. + // They are downloaded during mvn install but never appear in mvn dependency:tree, + // so without this step jf ca would miss curation violations that block the build. + // Skip if the tree is empty — no roots to attach to and no point running extra subprocesses. + if manager.mvnIncludePluginDeps && len(dependencyTree) > 0 { + for id, node := range manager.resolvePluginDeps() { + gavID := GavPackageTypeIdentifier + id + if _, exists := uniqueDeps[gavID]; !exists { + uniqueDeps[gavID] = node + // Attach to every module root so fillGraphRelations surfaces it in the report. + for _, moduleRoot := range dependencyTree { + moduleRoot.Nodes = append(moduleRoot.Nodes, &xrayUtils.GraphNode{Id: gavID, Types: node.Types}) + } + } + } + } return } +// resolvePluginDeps runs "mvn dependency:resolve-plugins" and returns all Maven build-plugin +// transitive dependencies keyed by "groupId:artifactId:version". Failure is non-fatal. +// +// The result is filtered by the install-lifecycle plugin allow-list resolved from the +// effective POM: only transitive deps of plugins that actually run during `mvn install` are +// returned. If the effective-pom resolution fails, the allow-list is nil and all plugin deps +// are returned (current behavior). +func (mdt *MavenDepTreeManager) resolvePluginDeps() map[string]*xray.DepTreeNode { + allowedPlugins := mdt.resolveInstallLifecyclePlugins() + + goals := []string{"dependency:resolve-plugins", "-B"} + if mdt.isCurationCmd && mdt.curationCacheFolder != "" { + goals = append(goals, "-Dmaven.repo.local="+mdt.curationCacheFolder) + } + output, err := mdt.RunMvnCmd(goals) + if err != nil { + log.Warn("Failed to resolve Maven plugin dependencies; plugin deps will not be included in curation evaluation:", err.Error()) + return nil + } + if allowedPlugins != nil { + log.Debug(fmt.Sprintf("[mvn-plugin-deps] effective-pom install-lifecycle allow-list (%d plugins):", len(allowedPlugins))) + for coord := range allowedPlugins { + log.Debug("[mvn-plugin-deps] allowed: " + coord) + } + } else { + log.Debug("[mvn-plugin-deps] effective-pom allow-list unavailable - reporting every plugin dep without lifecycle filter") + } + parsed := parseMavenPluginDepsWithTrace(string(output), allowedPlugins) + log.Info(fmt.Sprintf("[mvn-plugin-deps] %d Maven plugin transitive deps included in curation evaluation after install-lifecycle filter", len(parsed))) + return parsed +} + +// parseMavenPluginDepsWithTrace logs which top-level plugins were kept vs filtered as the +// parser walks `mvn dependency:resolve-plugins` output. Use this when investigating which +// allow-listed plugin is pulling a specific vulnerable transitive. The extra pass is cheap +// compared to `mvn dependency:resolve-plugins` itself and only emits at debug level. +func parseMavenPluginDepsWithTrace(output string, allowedPlugins map[string]struct{}) map[string]*xray.DepTreeNode { + for _, line := range strings.Split(output, "\n") { + m := mavenPluginLineRe.FindStringSubmatch(line) + if len(m) < 3 { + continue + } + coord := m[1] + ":" + m[2] + if allowedPlugins == nil { + log.Debug("[mvn-plugin-deps] top-level plugin (no filter active): " + coord) + continue + } + if _, ok := allowedPlugins[coord]; ok { + log.Debug("[mvn-plugin-deps] top-level plugin kept: " + coord) + } else { + log.Debug("[mvn-plugin-deps] top-level plugin filtered out: " + coord) + } + } + return parseMavenPluginDeps(output, allowedPlugins) +} + +// resolveInstallLifecyclePlugins runs "mvn help:effective-pom" and returns the set of +// "groupId:artifactId" for plugins bound to phases executed by `mvn install`. +// Plugins whose only executions target post-install phases (deploy/site/release) are excluded. +// Returns nil if effective-pom resolution fails — callers must treat nil as "no filter". +func (mdt *MavenDepTreeManager) resolveInstallLifecyclePlugins() map[string]struct{} { + outputFile, err := os.CreateTemp("", "effective-pom-*.xml") + if err != nil { + log.Warn("[mvn-plugin-deps] Failed to create temp file for effective POM; plugin filter disabled:", err.Error()) + return nil + } + outputPath := outputFile.Name() + _ = outputFile.Close() + // Preserve the file on parse failure so callers can inspect why no plugins were extracted. + preserveFile := false + defer func() { + if preserveFile { + log.Info("[mvn-plugin-deps] effective POM preserved for inspection at: " + outputPath) + return + } + if removeErr := os.Remove(outputPath); removeErr != nil && !os.IsNotExist(removeErr) { + log.Debug("[mvn-plugin-deps] failed to remove effective POM temp file:", removeErr.Error()) + } + }() + + goals := []string{"help:effective-pom", "-B", "-Doutput=" + outputPath} + if mdt.isCurationCmd && mdt.curationCacheFolder != "" { + goals = append(goals, "-Dmaven.repo.local="+mdt.curationCacheFolder) + } + log.Debug("[mvn-plugin-deps] running 'mvn " + strings.Join(goals, " ") + "' to build the install-lifecycle plugin allow-list") + mvnOutput, err := mdt.RunMvnCmd(goals) + if err != nil { + log.Warn("[mvn-plugin-deps] mvn help:effective-pom failed - plugin filter disabled, all plugin deps will be reported. Reason: " + err.Error()) + if len(mvnOutput) > 0 { + log.Debug("[mvn-plugin-deps] mvn output (tail):\n" + tailString(string(mvnOutput), 2000)) + } + return nil + } + + info, statErr := os.Stat(outputPath) + if statErr != nil { + log.Warn("[mvn-plugin-deps] effective POM output file missing after mvn run - plugin filter disabled. Reason: " + statErr.Error()) + return nil + } + if info.Size() == 0 { + log.Warn("[mvn-plugin-deps] effective POM output file is empty - plugin filter disabled. The maven-help-plugin version may not honor -Doutput=") + return nil + } + + data, err := os.ReadFile(outputPath) + if err != nil { + log.Warn("[mvn-plugin-deps] failed to read effective POM output - plugin filter disabled. Reason: " + err.Error()) + return nil + } + allowed := parseEffectivePomPluginCoordinates(string(data)) + if allowed == nil { + log.Warn(fmt.Sprintf("[mvn-plugin-deps] effective POM parsed to empty allow-list (file size %d bytes) - plugin filter disabled", info.Size())) + preserveFile = true + } + return allowed +} + +// tailString returns at most n bytes from the end of s, useful for trimming verbose +// mvn output to a manageable size in debug logs. +func tailString(s string, n int) string { + if len(s) <= n { + return s + } + return "..." + s[len(s)-n:] +} + +// postInstallPhases are lifecycle phases NOT executed by `mvn install`. +// A plugin whose only executions target these phases is excluded from the allow-list. +var postInstallPhases = map[string]bool{ + "pre-site": true, + "site": true, + "post-site": true, + "site-deploy": true, + "deploy": true, +} + +// postInstallPluginsByDefault lists plugins whose default goal phase is past `install`, +// even when the effective POM declares them without explicit . +// Such plugins are excluded unless the user explicitly binds them to an install-lifecycle phase. +var postInstallPluginsByDefault = map[string]bool{ + "org.apache.maven.plugins:maven-deploy-plugin": true, + "org.apache.maven.plugins:maven-site-plugin": true, + "org.apache.maven.plugins:maven-release-plugin": true, + "org.apache.maven.plugins:maven-gpg-plugin": true, +} + +// effectivePomProject mirrors the subset of fields we need from `mvn help:effective-pom`. +// A multi-module effective POM is wrapped in ; we stream-decode elements +// regardless of nesting depth so both single and multi-module outputs work. +type effectivePomProject struct { + XMLName xml.Name `xml:"project"` + Build effectivePomBuild `xml:"build"` +} + +type effectivePomBuild struct { + Plugins []effectivePomPlugin `xml:"plugins>plugin"` +} + +type effectivePomPlugin struct { + GroupID string `xml:"groupId"` + ArtifactID string `xml:"artifactId"` + Executions []effectivePomExecution `xml:"executions>execution"` +} + +type effectivePomExecution struct { + Phase string `xml:"phase"` +} + +// effectivePomXmlnsRe matches xmlns and xmlns:prefix attribute declarations. +// Maven emits the effective POM with xmlns="http://maven.apache.org/POM/4.0.0"; +// stripping it lets our namespace-agnostic struct tags match the actual elements. +var effectivePomXmlnsRe = regexp.MustCompile(`\s+xmlns(?::[^=\s]+)?="[^"]*"`) + +// mavenPluginLineRe matches a top-level maven-plugin header in dependency:resolve-plugins output. +var mavenPluginLineRe = regexp.MustCompile(`\[INFO\]\s+([\w.\-]+):([\w.\-]+):maven-plugin:`) + +// mavenCoordRe matches both plugin headers and transitive dep lines in dependency:resolve-plugins output. +var mavenCoordRe = regexp.MustCompile(`\[INFO\]\s+([\w.\-]+):([\w.\-]+):(jar|war|pom|ear|aar|ejb|bundle|test-jar|maven-plugin):([\w.\-]+)(?::([\w.\-]+))?`) + +// defaultPluginGroupID is the implicit groupId for plugins under the official Maven +// plugin namespace. The effective POM commonly omits for these plugins, +// relying on this default. +const defaultPluginGroupID = "org.apache.maven.plugins" + +// parseEffectivePomPluginCoordinates walks the effective POM XML and returns the +// allow-list of "groupId:artifactId" for plugins that participate in `mvn install`. +// Returns nil if the XML cannot be decoded — callers treat nil as "no filter". +func parseEffectivePomPluginCoordinates(xmlData string) map[string]struct{} { + // Strip xmlns declarations so the struct-tag matcher works regardless of the + // POM namespace declared by maven-help-plugin (defaults to maven.apache.org/POM/4.0.0). + xmlData = effectivePomXmlnsRe.ReplaceAllString(xmlData, "") + decoder := xml.NewDecoder(strings.NewReader(xmlData)) + allowed := map[string]struct{}{} + projectsSeen, pluginsSeen, pluginsAllowed := 0, 0, 0 + for { + tok, err := decoder.Token() + if err != nil { + break + } + start, ok := tok.(xml.StartElement) + if !ok || start.Name.Local != "project" { + continue + } + projectsSeen++ + var project effectivePomProject + if err := decoder.DecodeElement(&project, &start); err != nil { + // Skip malformed blocks; effective-pom for one module shouldn't fail the rest. + log.Debug("[mvn-plugin-deps] skipping malformed block in effective POM:", err.Error()) + continue + } + for _, p := range project.Build.Plugins { + pluginsSeen++ + groupID := p.GroupID + if groupID == "" { + // Maven's effective POM frequently omits for org.apache.maven.plugins. + groupID = defaultPluginGroupID + } + if p.ArtifactID == "" { + continue + } + coord := groupID + ":" + p.ArtifactID + if !isPluginInInstallLifecycle(coord, p.Executions) { + continue + } + allowed[coord] = struct{}{} + pluginsAllowed++ + } + } + log.Debug(fmt.Sprintf("[mvn-plugin-deps] effective POM scan: %d blocks, %d entries under , %d allowed", projectsSeen, pluginsSeen, pluginsAllowed)) + if len(allowed) == 0 { + // Empty allow-list almost certainly means malformed input — fall back to "no filter" + // rather than silently dropping every plugin dep. + return nil + } + return allowed +} + +// isPluginInInstallLifecycle returns true when the plugin's executions (or default phase) +// fall within phases executed by `mvn install`. +func isPluginInInstallLifecycle(coord string, executions []effectivePomExecution) bool { + defaultPostInstall := postInstallPluginsByDefault[coord] + + // Collect phases explicitly bound by user executions (empty means "use plugin default"). + var explicitPhases []string + for _, ex := range executions { + if ex.Phase != "" { + explicitPhases = append(explicitPhases, ex.Phase) + } + } + + if len(explicitPhases) == 0 { + // No explicit phase: rely on plugin's default phase. Exclude if known post-install. + return !defaultPostInstall + } + // At least one explicit phase exists: include if any binds to an install-lifecycle phase. + for _, phase := range explicitPhases { + if !postInstallPhases[phase] { + return true + } + } + return false +} + +// mavenKnownScopes distinguishes a Maven scope from a classifier in a 5-field coordinate +// (g:a:packaging:field4:field5). If field5 is a known scope, field4 is the version. +var mavenKnownScopes = map[string]bool{ + "compile": true, "runtime": true, "test": true, "provided": true, "system": true, +} + +// parseMavenPluginDeps parses "mvn dependency:resolve-plugins" output and returns a map of +// "groupId:artifactId:version" -> DepTreeNode for every resolved plugin dependency. +// +// When allowedPlugins is non-nil, only transitive deps of plugins in the allow-list are +// returned, filtering out plugins bound to post-install lifecycles (deploy, site, release). +// When allowedPlugins is nil all plugin deps are returned. +// +// Output formats matched: +// +// [INFO] g:a:maven-plugin:version:scope (top-level plugin — switches the active filter) +// [INFO] g:a:jar:version (transitive dep, no classifier) +// [INFO] g:a:jar:classifier:version (transitive dep with classifier — version is last) +func parseMavenPluginDeps(output string, allowedPlugins map[string]struct{}) map[string]*xray.DepTreeNode { + deps := map[string]*xray.DepTreeNode{} + // includeCurrent gates whether transitive deps under the most recently seen top-level + // plugin should be collected. nil allow-list means "include all". + includeCurrent := allowedPlugins == nil + for _, line := range strings.Split(output, "\n") { + m := mavenCoordRe.FindStringSubmatch(line) + if len(m) < 5 { + continue + } + groupID, artifactID, packaging := m[1], m[2], m[3] + version := m[4] + if m[5] != "" && !mavenKnownScopes[m[5]] { + // 5-field: g:a:packaging:classifier:version — m[4] is the classifier + version = m[5] + } + // else: g:a:packaging:version:scope — version is already m[4] + if packaging == "maven-plugin" { + // Top-level plugin line — update the active filter for the indented transitive deps below. + if allowedPlugins == nil { + includeCurrent = true + } else { + _, includeCurrent = allowedPlugins[groupID+":"+artifactID] + } + continue + } + if !includeCurrent { + continue + } + nodeID := groupID + ":" + artifactID + ":" + version + deps[nodeID] = &xray.DepTreeNode{Types: &[]string{packaging}} + } + return deps +} + // Runs maven-dep-tree according to cmdName. Returns the plugin output along with a function pointer to revert the plugin side effects. // If a non-nil clearMavenDepTreeRun pointer is returns it means we had no error during the entire function execution func (mdt *MavenDepTreeManager) RunMavenDepTree() (depTreeOutput string, clearMavenDepTreeRun func() error, err error) { diff --git a/sca/bom/buildinfo/technologies/java/mvn_test.go b/sca/bom/buildinfo/technologies/java/mvn_test.go index 3d5d990d9..7aca34eb1 100644 --- a/sca/bom/buildinfo/technologies/java/mvn_test.go +++ b/sca/bom/buildinfo/technologies/java/mvn_test.go @@ -439,6 +439,277 @@ func TestRemoveMavenConfig(t *testing.T) { assert.FileExists(t, mavenConfigPath) } +func TestParseMavenPluginDeps(t *testing.T) { + // Realistic "mvn dependency:resolve-plugins" output from Maven 3.9.x. + mvnOutput := ` +[INFO] Scanning for projects... +[INFO] +[INFO] --- dependency:3.7.0:resolve-plugins (default-cli) @ test-ignore-rules --- +[INFO] +[INFO] The following plugins have been resolved: +[INFO] org.apache.maven.plugins:maven-clean-plugin:maven-plugin:3.2.0:runtime +[INFO] org.apache.maven.plugins:maven-clean-plugin:jar:3.2.0 +[INFO] org.apache.maven.shared:maven-shared-utils:jar:3.3.4 +[INFO] org.apache.maven.plugins:maven-resources-plugin:maven-plugin:3.4.0:runtime +[INFO] org.apache.maven.plugins:maven-resources-plugin:jar:3.4.0 +[INFO] org.codehaus.plexus:plexus-utils:jar:4.0.2 +[INFO] org.apache.commons:commons-lang3:jar:3.20.0 +[INFO] commons-io:commons-io:jar:2.16.1 +[INFO] org.apache.maven.plugins:maven-compiler-plugin:maven-plugin:3.15.0:runtime +[INFO] org.apache.maven.plugins:maven-compiler-plugin:jar:3.15.0 +[INFO] org.ow2.asm:asm:jar:9.7 +[INFO] org.apache.maven.plugins:maven-site-plugin:maven-plugin:3.12.1:runtime +[INFO] org.eclipse.sisu:org.eclipse.sisu.plexus:jar:0.3.5 +[INFO] org.sonatype.sisu:sisu-guice:jar:no_aop:3.2.3 +[INFO] +[INFO] BUILD SUCCESS +` + deps := parseMavenPluginDeps(mvnOutput, nil) + + expectedKeys := []string{ + "org.apache.maven.plugins:maven-clean-plugin:3.2.0", + "org.apache.maven.shared:maven-shared-utils:3.3.4", + "org.apache.maven.plugins:maven-resources-plugin:3.4.0", + "org.codehaus.plexus:plexus-utils:4.0.2", + "org.apache.commons:commons-lang3:3.20.0", + "commons-io:commons-io:2.16.1", + "org.apache.maven.plugins:maven-compiler-plugin:3.15.0", + "org.ow2.asm:asm:9.7", + "org.eclipse.sisu:org.eclipse.sisu.plexus:0.3.5", + "org.sonatype.sisu:sisu-guice:3.2.3", // classifier "no_aop" — version must be 3.2.3 + } + assert.Len(t, deps, len(expectedKeys)) + for _, key := range expectedKeys { + assert.Contains(t, deps, key, "expected plugin dep %q to be parsed", key) + if node, ok := deps[key]; ok { + assert.NotNil(t, node.Types, "expected Types to be set for %q", key) + assert.NotEmpty(t, *node.Types, "expected at least one type for %q", key) + } + } + // plexus-utils must carry type "jar" so the curation HEAD check builds the correct URL + plexusNode := deps["org.codehaus.plexus:plexus-utils:4.0.2"] + if assert.NotNil(t, plexusNode) && assert.NotNil(t, plexusNode.Types) { + assert.Contains(t, *plexusNode.Types, "jar") + } +} + +func TestParseMavenPluginDepsEmpty(t *testing.T) { + assert.Empty(t, parseMavenPluginDeps("", nil)) + assert.Empty(t, parseMavenPluginDeps("[INFO] BUILD SUCCESS\n[INFO] some random line", nil)) +} + +func TestParseMavenPluginDepsScopeSuffix(t *testing.T) { + // Verifies that a known Maven scope in the 5th colon-field is not mistaken for a version. + // A line like "g:a:jar:1.0:compile" must produce key "g:a:1.0", not "g:a:compile". + output := "[INFO] commons-io:commons-io:jar:2.16.1:compile\n" + + "[INFO] org.sonatype.sisu:sisu-guice:jar:no_aop:3.2.3\n" + deps := parseMavenPluginDeps(output, nil) + assert.Contains(t, deps, "commons-io:commons-io:2.16.1", "scope suffix should not become the version") + assert.NotContains(t, deps, "commons-io:commons-io:compile", "version must not be the scope") + assert.Contains(t, deps, "org.sonatype.sisu:sisu-guice:3.2.3", "classifier path (no_aop) must still resolve correctly") +} + +func TestParseMavenPluginDepsSkipsNonCoordinateLines(t *testing.T) { + output := ` +[INFO] Building my-project 1.0-SNAPSHOT +[INFO] --- dependency:3.7.0:resolve-plugins @ my-project --- +[INFO] org.apache.maven.plugins:maven-jar-plugin:maven-plugin:3.3.0:runtime +[INFO] org.apache.maven.plugins:maven-jar-plugin:jar:3.3.0 +[INFO] org.apache.maven.shared:maven-shared-utils:jar:3.3.4 +[WARNING] Some warning line +[ERROR] some error that should be skipped +` + deps := parseMavenPluginDeps(output, nil) + assert.Len(t, deps, 2) + assert.Contains(t, deps, "org.apache.maven.plugins:maven-jar-plugin:3.3.0") + assert.Contains(t, deps, "org.apache.maven.shared:maven-shared-utils:3.3.4") +} + +func TestParseMavenPluginDepsFiltersByAllowList(t *testing.T) { + // Same realistic Maven 3.9 output as TestParseMavenPluginDeps; allow-list excludes + // maven-site-plugin so its transitive deps (sisu.plexus, sisu-guice) must be dropped. + mvnOutput := ` +[INFO] --- dependency:3.7.0:resolve-plugins (default-cli) @ test-ignore-rules --- +[INFO] org.apache.maven.plugins:maven-resources-plugin:maven-plugin:3.4.0:runtime +[INFO] org.apache.maven.plugins:maven-resources-plugin:jar:3.4.0 +[INFO] org.codehaus.plexus:plexus-utils:jar:4.0.2 +[INFO] org.apache.maven.plugins:maven-site-plugin:maven-plugin:3.12.1:runtime +[INFO] org.eclipse.sisu:org.eclipse.sisu.plexus:jar:0.3.5 +[INFO] org.sonatype.sisu:sisu-guice:jar:no_aop:3.2.3 +[INFO] org.apache.maven.plugins:maven-compiler-plugin:maven-plugin:3.15.0:runtime +[INFO] org.ow2.asm:asm:jar:9.7 +` + allowed := map[string]struct{}{ + "org.apache.maven.plugins:maven-resources-plugin": {}, + "org.apache.maven.plugins:maven-compiler-plugin": {}, + } + deps := parseMavenPluginDeps(mvnOutput, allowed) + + assert.Contains(t, deps, "org.apache.maven.plugins:maven-resources-plugin:3.4.0") + assert.Contains(t, deps, "org.codehaus.plexus:plexus-utils:4.0.2") + assert.Contains(t, deps, "org.ow2.asm:asm:9.7") + assert.NotContains(t, deps, "org.eclipse.sisu:org.eclipse.sisu.plexus:0.3.5", "site-plugin transitive dep must be filtered out") + assert.NotContains(t, deps, "org.sonatype.sisu:sisu-guice:3.2.3", "site-plugin transitive dep must be filtered out") +} + +func TestParseEffectivePomPluginCoordinatesIncludesInstallLifecycle(t *testing.T) { + xmlData := ` + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.4.0 + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.4 + + + org.apache.maven.plugins + maven-site-plugin + 3.12.1 + + + +` + allowed := parseEffectivePomPluginCoordinates(xmlData) + + assert.Contains(t, allowed, "org.apache.maven.plugins:maven-resources-plugin") + assert.Contains(t, allowed, "org.apache.maven.plugins:maven-compiler-plugin") + assert.NotContains(t, allowed, "org.apache.maven.plugins:maven-deploy-plugin", "deploy-plugin runs past install — excluded") + assert.NotContains(t, allowed, "org.apache.maven.plugins:maven-site-plugin", "site-plugin runs past install — excluded") +} + +func TestParseEffectivePomPluginCoordinatesUserBoundDeployIncluded(t *testing.T) { + // User explicitly rebinds maven-deploy-plugin to the `package` phase — it should be + // included even though its default phase is post-install. + xmlData := ` + + + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.4 + + + custom-pkg + package + + + + + +` + allowed := parseEffectivePomPluginCoordinates(xmlData) + assert.Contains(t, allowed, "org.apache.maven.plugins:maven-deploy-plugin") +} + +func TestParseEffectivePomPluginCoordinatesAllPostInstallExecutionsExcluded(t *testing.T) { + // User-declared plugin (not in the default deny-list) whose only execution targets + // a post-install phase — should be filtered out. + xmlData := ` + + + + + com.example + my-deploy-only-plugin + 1.0 + + + only-on-deploy + deploy + + + + + +` + allowed := parseEffectivePomPluginCoordinates(xmlData) + assert.NotContains(t, allowed, "com.example:my-deploy-only-plugin") +} + +func TestParseEffectivePomPluginCoordinatesMultiModule(t *testing.T) { + // Multi-module effective POM: wrapping multiple elements. + // Plugins from every module should be accumulated into a single allow-list. + xmlData := ` + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + + + + + com.example + custom-plugin + + + + +` + allowed := parseEffectivePomPluginCoordinates(xmlData) + assert.Contains(t, allowed, "org.apache.maven.plugins:maven-compiler-plugin") + assert.Contains(t, allowed, "com.example:custom-plugin") +} + +func TestParseEffectivePomPluginCoordinatesMalformedReturnsNil(t *testing.T) { + // Malformed XML must not panic and must return nil so callers fall back to "no filter". + assert.Nil(t, parseEffectivePomPluginCoordinates("")) + assert.Nil(t, parseEffectivePomPluginCoordinates("not xml at all")) +} + +func TestParseEffectivePomPluginCoordinatesHandlesMavenDefaultNamespace(t *testing.T) { + // Real maven-help-plugin output declares xmlns="http://maven.apache.org/POM/4.0.0" on + // the root and xsi:schemaLocation on each module. Without stripping the + // xmlns declarations, Go's encoding/xml refuses to match the namespaced elements and + // the allow-list comes back empty — which silently disables the filter. + xmlData := ` + + 4.0.0 + org.example + test-ignore-rules + 1.0-SNAPSHOT + + + + org.apache.maven.plugins + maven-resources-plugin + 3.4.0 + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.4 + + + +` + allowed := parseEffectivePomPluginCoordinates(xmlData) + + assert.NotNil(t, allowed, "namespaced effective POM must produce a non-nil allow-list") + assert.Contains(t, allowed, "org.apache.maven.plugins:maven-resources-plugin") + assert.NotContains(t, allowed, "org.apache.maven.plugins:maven-deploy-plugin", "deploy-plugin must still be filtered when namespace is stripped") +} + func TestNewMavenDepTreeManagerPreservesAllParams(t *testing.T) { server := &config.ServerDetails{ArtifactoryUrl: "https://test.jfrog.io/artifactory"} params := &DepTreeParams{