From 7322a0760c7bd8b4d3c86cec848fdc6be253450a Mon Sep 17 00:00:00 2001 From: Gauri Yadav Date: Mon, 15 Jun 2026 10:26:23 +0530 Subject: [PATCH] XRAY-145307 - Transfered logic to include plugins deps as well in maven dep tree --- cli/docs/flags.go | 2 +- commands/curation/curationaudit_test.go | 11 +- .../technologies/java/deptreemanager.go | 63 +++- .../technologies/java/deptreemanager_test.go | 118 ++++++ sca/bom/buildinfo/technologies/java/mvn.go | 338 ++--------------- .../buildinfo/technologies/java/mvn_test.go | 346 ++---------------- 6 files changed, 245 insertions(+), 633 deletions(-) diff --git a/cli/docs/flags.go b/cli/docs/flags.go index a44e373a7..677fa7719 100644 --- a/cli/docs/flags.go +++ b/cli/docs/flags.go @@ -351,7 +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."), + MvnIncludePluginDeps: components.NewBoolFlag(MvnIncludePluginDeps, "[Maven] When set to true, Maven build-plugin transitive dependencies are resolved and included in the curation evaluation. 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/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 010b64953..2ff186d59 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -915,16 +915,15 @@ func getTestCasesForDoCurationAudit() []testCase { curationCache, err := utils.GetCurationCacheFolderByTech(techutils.Maven.String()) require.NoError(t, err) cleanUpTestDirChange() - // One mvn invocation, multiple goals: maven-dep-tree:tree primes the project - // dep cache; dependency:resolve-plugins and help:effective-pom pre-download - // the plugins that resolvePluginDeps()/resolveInstallLifecyclePlugins() will - // re-run during the test phase against the mock server. + // Pre-populate the curation cache with all plugin artifact downloads so that + // during the actual test run against the mock server only the blocked artifact + // triggers an HTTP request. The -DincludePluginDeps=true flag causes + // maven-dep-tree to resolve plugin transitive deps in the same invocation. return []string{ "com.jfrog:maven-dep-tree:" + java.GetMavenDepTreeVersion() + ":tree", "-DdepsTreeOutputFile=output", "-Dmaven.repo.local=" + curationCache, - "dependency:resolve-plugins", - "help:effective-pom", + "-DincludePluginDeps=true", } }, mvnIncludePluginDeps: true, diff --git a/sca/bom/buildinfo/technologies/java/deptreemanager.go b/sca/bom/buildinfo/technologies/java/deptreemanager.go index 53fa62030..437462a43 100644 --- a/sca/bom/buildinfo/technologies/java/deptreemanager.go +++ b/sca/bom/buildinfo/technologies/java/deptreemanager.go @@ -3,6 +3,7 @@ package java import ( "encoding/json" "os" + "sort" "strings" "github.com/jfrog/jfrog-cli-security/utils/techutils" @@ -47,14 +48,27 @@ func NewDepTreeManager(params *DepTreeParams) DepTreeManager { } // The structure of a dependency tree of a module in a Gradle/Maven project, as created by the gradle-dep-tree and maven-dep-tree plugins. +// PluginNodes is populated by maven-dep-tree when -DincludePluginDeps=true is passed; it contains +// transitive dependencies of Maven build plugins that participate in the install lifecycle. type moduleDepTree struct { - Root string `json:"root"` - Nodes map[string]xray.DepTreeNode `json:"nodes"` + Root string `json:"root"` + Nodes map[string]xray.DepTreeNode `json:"nodes"` + PluginNodes map[string]xray.DepTreeNode `json:"pluginNodes,omitempty"` } // Reads the output files of the gradle-dep-tree and maven-dep-tree plugins and returns them as a slice of GraphNodes. // It takes the output of the plugin's run (which is a byte representation of a list of paths of the output files, separated by newlines) as input. +// Thin wrapper over getGraphAndPluginDepsFromDepTree for callers that don't need plugin deps. func getGraphFromDepTree(outputFilePaths string) (depsGraph []*xrayUtils.GraphNode, uniqueDepsMap map[string]*xray.DepTreeNode, err error) { + depsGraph, uniqueDepsMap, _, _, err = getGraphAndPluginDepsFromDepTree(outputFilePaths) + return +} + +// getGraphAndPluginDepsFromDepTree returns the dependency graph and flat unique-deps map, plus the +// plugin transitive deps emitted under "pluginNodes" when -DincludePluginDeps=true. pluginDepsMap is +// nil when none were collected; pluginNodesPresent is true when the field was emitted (even empty), +// distinguishing "ran, nothing to inject" from "plugin ignored the flag". +func getGraphAndPluginDepsFromDepTree(outputFilePaths string) (depsGraph []*xrayUtils.GraphNode, uniqueDepsMap map[string]*xray.DepTreeNode, pluginDepsMap map[string]*xray.DepTreeNode, pluginNodesPresent bool, err error) { modules, err := parseDepTreeFiles(outputFilePaths) if err != nil { return @@ -66,10 +80,55 @@ func getGraphFromDepTree(outputFilePaths string) (depsGraph []*xrayUtils.GraphNo for depToAdd, depTypes := range moduleUniqueDeps { uniqueDepsMap[depToAdd] = depTypes } + // A non-nil map (including an empty {}) means the plugin emitted the field. + if module.PluginNodes != nil { + pluginNodesPresent = true + } + for gav, node := range module.PluginNodes { + if pluginDepsMap == nil { + pluginDepsMap = map[string]*xray.DepTreeNode{} + } + existing, exists := pluginDepsMap[gav] + if !exists { + pluginDepsMap[gav] = &xray.DepTreeNode{ + Types: node.Types, + Classifier: node.Classifier, + Configurations: node.Configurations, + } + continue + } + // Same GAV in another module: keep the first module's classifier/configurations but + // union the types so a single-module variant (e.g. test-jar) is still curated. + existing.Types = mergePluginNodeTypes(existing.Types, node.Types) + } } return } +// mergePluginNodeTypes returns the deduplicated, sorted union of two artifact-type lists, so a +// plugin dep appearing in multiple modules keeps every type variant. Returns nil when both are empty. +func mergePluginNodeTypes(existing, incoming *[]string) *[]string { + seen := map[string]struct{}{} + var merged []string + for _, src := range []*[]string{existing, incoming} { + if src == nil { + continue + } + for _, t := range *src { + if _, ok := seen[t]; ok { + continue + } + seen[t] = struct{}{} + merged = append(merged, t) + } + } + if merged == nil { + return nil + } + sort.Strings(merged) + return &merged +} + // Returns a dependency tree and a flat list of the module's dependencies for the given module func GetModuleTreeAndDependencies(module *moduleDepTree) (*xrayUtils.GraphNode, map[string]*xray.DepTreeNode) { moduleTreeMap := make(map[string]xray.DepTreeNode) diff --git a/sca/bom/buildinfo/technologies/java/deptreemanager_test.go b/sca/bom/buildinfo/technologies/java/deptreemanager_test.go index 8464fea43..cae3746ab 100644 --- a/sca/bom/buildinfo/technologies/java/deptreemanager_test.go +++ b/sca/bom/buildinfo/technologies/java/deptreemanager_test.go @@ -1,6 +1,7 @@ package java import ( + "fmt" "os" "path/filepath" "reflect" @@ -84,3 +85,120 @@ func TestGetGradleGraphFromDepTreeWithCuration(t *testing.T) { assert.NotEmpty(t, depTree) assert.NotEmpty(t, uniqueDeps) } + +// writeDepTreeModuleFiles writes each maven-dep-tree module JSON to its own temp file and +// returns the newline-separated list of paths that getGraph*FromDepTree expects as input. +func writeDepTreeModuleFiles(t *testing.T, modulesJSON ...string) string { + dir := t.TempDir() + paths := make([]string, 0, len(modulesJSON)) + for i, content := range modulesJSON { + p := filepath.Join(dir, fmt.Sprintf("module-%d.json", i)) + assert.NoError(t, os.WriteFile(p, []byte(content), 0600)) + paths = append(paths, p) + } + return strings.Join(paths, "\n") +} + +func TestGetGraphAndPluginDepsFromDepTreeSingleModule(t *testing.T) { + moduleJSON := `{ + "root": "org.example:app:1.0", + "nodes": { + "org.example:app:1.0": {"classifier": null, "types": ["jar"], "children": ["commons-io:commons-io:2.11.0"]}, + "commons-io:commons-io:2.11.0": {"classifier": null, "types": ["jar"], "children": []} + }, + "pluginNodes": { + "org.ow2.asm:asm:9.8": {"classifier": null, "types": ["jar"], "children": []}, + "commons-io:commons-io:2.21.0": {"classifier": "sources", "types": ["jar"], "children": [], "configurations": ["compile"]} + } + }` + depsGraph, uniqueDeps, pluginDeps, pluginNodesPresent, err := getGraphAndPluginDepsFromDepTree(writeDepTreeModuleFiles(t, moduleJSON)) + assert.NoError(t, err) + assert.Len(t, depsGraph, 1) + assert.NotEmpty(t, uniqueDeps) + + assert.True(t, pluginNodesPresent, "pluginNodes field is present") + assert.NotNil(t, pluginDeps) + assert.Len(t, pluginDeps, 2) + + asm := pluginDeps["org.ow2.asm:asm:9.8"] + assert.NotNil(t, asm) + assert.Equal(t, []string{"jar"}, *asm.Types) + assert.Nil(t, asm.Classifier) + + // Classifier and Configurations must be carried over from the plugin node (A4). + commonsIo := pluginDeps["commons-io:commons-io:2.21.0"] + assert.NotNil(t, commonsIo) + assert.Equal(t, "sources", *commonsIo.Classifier) + assert.Equal(t, []string{"compile"}, *commonsIo.Configurations) +} + +func TestGetGraphAndPluginDepsFromDepTreeNoPluginNodes(t *testing.T) { + moduleJSON := `{ + "root": "org.example:app:1.0", + "nodes": {"org.example:app:1.0": {"classifier": null, "types": ["jar"], "children": []}} + }` + depsGraph, uniqueDeps, pluginDeps, pluginNodesPresent, err := getGraphAndPluginDepsFromDepTree(writeDepTreeModuleFiles(t, moduleJSON)) + assert.NoError(t, err) + assert.Len(t, depsGraph, 1) + assert.NotEmpty(t, uniqueDeps) + // No "pluginNodes" field -> nil map and pluginNodesPresent=false, so callers can tell + // "plugin ignored the flag" from "ran but found nothing". + assert.False(t, pluginNodesPresent, "pluginNodes field is absent") + assert.Nil(t, pluginDeps) +} + +func TestGetGraphAndPluginDepsFromDepTreeMultiModuleDedup(t *testing.T) { + moduleA := `{ + "root": "org.example:app-a:1.0", + "nodes": {"org.example:app-a:1.0": {"classifier": null, "types": ["jar"], "children": []}}, + "pluginNodes": { + "org.ow2.asm:asm:9.8": {"classifier": null, "types": ["jar"], "children": []}, + "commons-io:commons-io:2.21.0": {"classifier": null, "types": ["jar"], "children": []} + } + }` + moduleB := `{ + "root": "org.example:app-b:1.0", + "nodes": {"org.example:app-b:1.0": {"classifier": null, "types": ["jar"], "children": []}}, + "pluginNodes": { + "org.ow2.asm:asm:9.8": {"classifier": null, "types": ["test-jar"], "children": []}, + "org.codehaus.plexus:plexus-utils:4.0.2": {"classifier": null, "types": ["jar"], "children": []} + } + }` + depsGraph, _, pluginDeps, pluginNodesPresent, err := getGraphAndPluginDepsFromDepTree(writeDepTreeModuleFiles(t, moduleA, moduleB)) + assert.NoError(t, err) + assert.Len(t, depsGraph, 2) + + assert.True(t, pluginNodesPresent, "pluginNodes field is present") + assert.NotNil(t, pluginDeps) + assert.Len(t, pluginDeps, 3) + // Types are unioned across modules (sorted), so module B's "test-jar" isn't dropped. + asm := pluginDeps["org.ow2.asm:asm:9.8"] + assert.NotNil(t, asm) + assert.Equal(t, []string{"jar", "test-jar"}, *asm.Types) + assert.Contains(t, pluginDeps, "commons-io:commons-io:2.21.0") + assert.Contains(t, pluginDeps, "org.codehaus.plexus:plexus-utils:4.0.2") +} + +func TestMergePluginNodeTypes(t *testing.T) { + sp := func(s ...string) *[]string { return &s } + cases := []struct { + name string + a, b *[]string + want *[]string + }{ + {"both nil", nil, nil, nil}, + {"left nil", nil, sp("jar"), sp("jar")}, + {"right nil", sp("jar"), nil, sp("jar")}, + {"dedup and sort", sp("test-jar", "jar"), sp("jar"), sp("jar", "test-jar")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := mergePluginNodeTypes(tc.a, tc.b) + if tc.want == nil { + assert.Nil(t, got) + return + } + assert.Equal(t, *tc.want, *got) + }) + } +} diff --git a/sca/bom/buildinfo/technologies/java/mvn.go b/sca/bom/buildinfo/technologies/java/mvn.go index 15490eb1d..864303fce 100644 --- a/sca/bom/buildinfo/technologies/java/mvn.go +++ b/sca/bom/buildinfo/technologies/java/mvn.go @@ -3,19 +3,15 @@ package java import ( "bytes" _ "embed" - "encoding/xml" "errors" "fmt" - "io/fs" "net/url" "os" "os/exec" "path" "path/filepath" - "regexp" "strings" "text/template" - "unicode/utf8" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" "github.com/jfrog/jfrog-cli-security/utils/techutils" @@ -89,17 +85,32 @@ func buildMavenDependencyTree(params *DepTreeParams) (dependencyTree []*xrayUtil defer func() { err = errors.Join(err, clearMavenDepTreeRun()) }() - dependencyTree, uniqueDeps, err = getGraphFromDepTree(outputFilePaths) + var pluginDeps map[string]*xray.DepTreeNode + var pluginNodesPresent bool + dependencyTree, uniqueDeps, pluginDeps, pluginNodesPresent, err = getGraphAndPluginDepsFromDepTree(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. - // To move this logic to maven-dep-tree - XRAY-145307 + // Skip if the tree is empty — no roots to attach to. + // The "--mvn-include-plugin-deps" literal below mirrors flags.MvnIncludePluginDeps + // (cli/docs/flags.go); duplicated as a string to avoid a cli->sca import cycle. if manager.mvnIncludePluginDeps && len(dependencyTree) > 0 { - injectPluginDeps(uniqueDeps, dependencyTree, manager.resolvePluginDeps()) + switch { + case len(pluginDeps) > 0: + injectPluginDeps(uniqueDeps, dependencyTree, pluginDeps) + case pluginNodesPresent: + // Plugin ran but the plugin-deps section is empty: nothing to inject. + log.Debug("'--mvn-include-plugin-deps' is set: maven-dep-tree reported no build-plugin dependencies to include.") + default: + // No plugin-deps section at all: the maven-dep-tree version pre-dates the feature. + // Warn so plugin deps aren't silently skipped from the curation evaluation. + log.Warn("'--mvn-include-plugin-deps' is set but the resolved maven-dep-tree plugin did not report a " + + "plugin-dependencies section; plugin dependencies will not be included in the curation evaluation. " + + "This usually means the maven-dep-tree plugin version does not support plugin dependency resolution.") + } } return } @@ -114,315 +125,9 @@ func injectPluginDeps(uniqueDeps map[string]*xray.DepTreeNode, dependencyTree [] } uniqueDeps[gavID] = node for _, moduleRoot := range dependencyTree { - moduleRoot.Nodes = append(moduleRoot.Nodes, &xrayUtils.GraphNode{Id: gavID, Types: node.Types}) - } - } -} - -// 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("[mvn-plugin-deps] 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) + moduleRoot.Nodes = append(moduleRoot.Nodes, &xrayUtils.GraphNode{Id: gavID, Types: node.Types, Classifier: node.Classifier}) } - } else { - log.Debug("[mvn-plugin-deps] effective-pom allow-list unavailable - reporting every plugin dep without lifecycle filter") - } - parsed := parseMavenPluginDeps(string(output), allowedPlugins) - if allowedPlugins != nil { - log.Info(fmt.Sprintf("[mvn-plugin-deps] %d plugin transitive deps included after install-lifecycle filter", len(parsed))) - } else { - log.Info(fmt.Sprintf("[mvn-plugin-deps] %d plugin transitive deps included (lifecycle filter unavailable — all reported)", len(parsed))) - } - return parsed -} - -// 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() - if closeErr := outputFile.Close(); closeErr != nil { - // Benign: mvn reopens the path via -Doutput=. Log so the rare failure is greppable. - log.Debug("[mvn-plugin-deps] temp file close after CreateTemp failed (benign):", closeErr.Error()) - } - // Preserve the file on parse failure so callers can inspect why no plugins were extracted. - preserveFile := false - defer func() { - if preserveFile { - log.Warn("[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 - } - - // #nosec G304 -- outputPath is from os.CreateTemp above, system-generated under $TMPDIR with a random suffix; never user-controlled. - data, err := os.ReadFile(outputPath) - if errors.Is(err, fs.ErrNotExist) { - log.Warn("[mvn-plugin-deps] effective POM output file missing after mvn run - plugin filter disabled. Reason:", err.Error()) - return nil - } - if err != nil { - log.Warn("[mvn-plugin-deps] failed to read effective POM output - plugin filter disabled. Reason:", err.Error()) - return nil - } - if len(data) == 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 - } - 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", len(data))) - preserveFile = true - } - return allowed -} - -// tailString returns roughly the last n bytes of s, advancing to the next rune -// boundary so the result is always valid UTF-8 (off by at most 3 bytes vs n). -func tailString(s string, n int) string { - if len(s) <= n { - return s - } - start := len(s) - n - for start < len(s) && !utf8.RuneStart(s[start]) { - start++ } - return "..." + s[start:] -} - -// phasesNotRunByInstall is the set of lifecycle phases that `mvn install` never executes. -// Covers the single Default-lifecycle phase past install (deploy), the entire Site -// lifecycle, and the entire Clean lifecycle. A plugin whose only executions target -// these phases is excluded from the allow-list. -var phasesNotRunByInstall = map[string]struct{}{ - "pre-site": {}, - "site": {}, - "post-site": {}, - "site-deploy": {}, - "deploy": {}, - "pre-clean": {}, - "clean": {}, - "post-clean": {}, -} - -// 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]struct{}{ - "org.apache.maven.plugins:maven-deploy-plugin": {}, - "org.apache.maven.plugins:maven-site-plugin": {}, - "org.apache.maven.plugins:maven-release-plugin": {}, - "org.apache.maven.plugins:maven-gpg-plugin": {}, -} - -// 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]+)?="[^"]*"`) - -// 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 projectsSeen == 0 { - // No parsed — treat as malformed and fall back to "no filter". - // An empty (non-nil) map is a valid result when every plugin was filtered out. - 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 { - // Single pass: keep an include if any explicit phase is in the install lifecycle, - // otherwise fall back to the plugin's default phase. - hasExplicit := false - for _, ex := range executions { - if ex.Phase == "" { - continue - } - hasExplicit = true - if _, skip := phasesNotRunByInstall[ex.Phase]; !skip { - return true - } - } - if !hasExplicit { - _, isPostInstall := postInstallPluginsByDefault[coord] - return !isPostInstall - } - 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.SplitSeq(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. - coord := groupID + ":" + artifactID - if allowedPlugins == nil { - includeCurrent = true - log.Debug("[mvn-plugin-deps] top-level plugin (no filter active):", coord) - } else if _, ok := allowedPlugins[coord]; ok { - includeCurrent = true - log.Debug("[mvn-plugin-deps] top-level plugin kept:", coord) - } else { - includeCurrent = false - log.Debug("[mvn-plugin-deps] top-level plugin filtered out:", coord) - } - 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. @@ -484,6 +189,9 @@ func (mdt *MavenDepTreeManager) runTreeCmd(depTreeExecDir string) (string, error if mdt.isCurationCmd { goals = append(goals, "-Dmaven.repo.local="+mdt.curationCacheFolder) } + if mdt.mvnIncludePluginDeps { + goals = append(goals, "-DincludePluginDeps=true") + } if _, err := mdt.RunMvnCmd(goals); err != nil { return "", err } diff --git a/sca/bom/buildinfo/technologies/java/mvn_test.go b/sca/bom/buildinfo/technologies/java/mvn_test.go index 2ea7667ec..fbb1606c6 100644 --- a/sca/bom/buildinfo/technologies/java/mvn_test.go +++ b/sca/bom/buildinfo/technologies/java/mvn_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "strings" "testing" - "unicode/utf8" "github.com/jfrog/build-info-go/utils" "github.com/jfrog/jfrog-cli-core/v2/utils/config" @@ -339,9 +338,12 @@ func TestDepTreeWithDedicatedCache(t *testing.T) { } func TestGetMavenPluginInstallationArgs(t *testing.T) { - args := GetMavenPluginInstallationGoals("testPlugin") - assert.Equal(t, "org.apache.maven.plugins:maven-install-plugin:3.1.1:install-file", args[0]) - assert.Equal(t, "-Dfile=testPlugin", args[1]) + expected := []string{ + "org.apache.maven.plugins:maven-install-plugin:3.1.1:install-file", + "-Dfile=testPlugin", + "-B", + } + assert.Equal(t, expected, GetMavenPluginInstallationGoals("testPlugin")) } func TestCreateSettingsXmlWithConfiguredArtifactory(t *testing.T) { @@ -442,278 +444,6 @@ func TestRemoveMavenConfig(t *testing.T) { assert.FileExists(t, mavenConfigPath) } -func TestParseMavenPluginDeps(t *testing.T) { - t.Parallel() - // 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) { - t.Parallel() - assert.Empty(t, parseMavenPluginDeps("", nil)) - assert.Empty(t, parseMavenPluginDeps("[INFO] BUILD SUCCESS\n[INFO] some random line", nil)) -} - -func TestParseMavenPluginDepsScopeSuffix(t *testing.T) { - t.Parallel() - // 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) { - t.Parallel() - 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) { - t.Parallel() - // 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 TestParseEffectivePomPluginCoordinates(t *testing.T) { - t.Parallel() - cases := []struct { - name string - xmlData string - wantNil bool - included []string - excluded []string - }{ - { - name: "install-lifecycle plugins included, post-install plugins excluded", - xmlData: ` - - - - org.apache.maven.pluginsmaven-resources-plugin3.4.0 - org.apache.maven.pluginsmaven-compiler-plugin3.15.0 - org.apache.maven.pluginsmaven-deploy-plugin3.1.4 - org.apache.maven.pluginsmaven-site-plugin3.12.1 - - -`, - included: []string{ - "org.apache.maven.plugins:maven-resources-plugin", - "org.apache.maven.plugins:maven-compiler-plugin", - }, - excluded: []string{ - "org.apache.maven.plugins:maven-deploy-plugin", - "org.apache.maven.plugins:maven-site-plugin", - }, - }, - { - name: "user rebinds deploy-plugin to install-lifecycle phase — included", - xmlData: ` - - - - - org.apache.maven.plugins - maven-deploy-plugin - 3.1.4 - custom-pkgpackage - - - -`, - included: []string{"org.apache.maven.plugins:maven-deploy-plugin"}, - }, - { - name: "user plugin with only post-install executions — excluded", - xmlData: ` - - - - - com.example - my-deploy-only-plugin - 1.0 - only-on-deploydeploy - - - -`, - excluded: []string{"com.example:my-deploy-only-plugin"}, - }, - { - // mvn install does not invoke the Clean lifecycle; a plugin bound only to it - // must not contribute its transitive deps to the curation evaluation. - name: "user plugin bound only to clean phase — excluded", - xmlData: ` - - - - - com.example - my-clean-only-plugin - 1.0 - only-on-cleanclean - - - -`, - excluded: []string{"com.example:my-clean-only-plugin"}, - }, - { - name: "multi-module: plugins from every accumulate", - xmlData: ` - - - org.apache.maven.pluginsmaven-compiler-plugin - - - com.examplecustom-plugin - -`, - included: []string{ - "org.apache.maven.plugins:maven-compiler-plugin", - "com.example:custom-plugin", - }, - }, - { - name: "empty input returns nil (callers fall back to no-filter)", - xmlData: "", - wantNil: true, - }, - { - name: "non-XML input returns nil", - xmlData: "not xml at all", - wantNil: true, - }, - { - // Real maven-help-plugin output declares xmlns="http://maven.apache.org/POM/4.0.0". - // Without stripping xmlns, encoding/xml returns an empty allow-list and silently - // disables the filter. - name: "default Maven namespace is stripped before parsing", - xmlData: ` - - 4.0.0 - org.example - test-ignore-rules - 1.0-SNAPSHOT - - - org.apache.maven.pluginsmaven-resources-plugin3.4.0 - org.apache.maven.pluginsmaven-deploy-plugin3.1.4 - - -`, - included: []string{"org.apache.maven.plugins:maven-resources-plugin"}, - excluded: []string{"org.apache.maven.plugins:maven-deploy-plugin"}, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := parseEffectivePomPluginCoordinates(tc.xmlData) - if tc.wantNil { - assert.Nil(t, got) - return - } - assert.NotNil(t, got, "non-empty XML must produce a non-nil allow-list") - for _, k := range tc.included { - assert.Contains(t, got, k) - } - for _, k := range tc.excluded { - assert.NotContains(t, got, k) - } - }) - } -} - func TestNewMavenDepTreeManagerPreservesAllParams(t *testing.T) { t.Parallel() server := &config.ServerDetails{ArtifactoryUrl: "https://test.jfrog.io/artifactory"} @@ -747,17 +477,19 @@ func TestNewMavenDepTreeManagerPreservesAllParams(t *testing.T) { func TestInjectPluginDeps(t *testing.T) { t.Parallel() jarType := func() *[]string { t := []string{"jar"}; return &t } + strPtr := func(s string) *string { return &s } existing := &xray.DepTreeNode{Types: jarType()} cases := []struct { - name string - uniqueDeps map[string]*xray.DepTreeNode - dependencyTree []*xrayUtils.GraphNode - pluginDeps map[string]*xray.DepTreeNode - wantUniqueDeps []string - wantRootChildren map[string][]string - wantExistingKept bool + name string + uniqueDeps map[string]*xray.DepTreeNode + dependencyTree []*xrayUtils.GraphNode + pluginDeps map[string]*xray.DepTreeNode + wantUniqueDeps []string + wantRootChildren map[string][]string + wantExistingKept bool + wantChildClassifier map[string]string }{ { name: "empty plugin deps is a no-op", @@ -800,6 +532,23 @@ func TestInjectPluginDeps(t *testing.T) { "gav://org.example:m2:1.0": {"gav://commons-io:commons-io:2.11.0"}, }, }, + { + name: "classifier is propagated to the fanned-out module-root node", + uniqueDeps: map[string]*xray.DepTreeNode{}, + dependencyTree: []*xrayUtils.GraphNode{ + {Id: "gav://org.example:m1:1.0"}, + }, + pluginDeps: map[string]*xray.DepTreeNode{ + "org.ow2.asm:asm:9.8": {Types: jarType(), Classifier: strPtr("tests")}, + }, + wantUniqueDeps: []string{"gav://org.ow2.asm:asm:9.8"}, + wantRootChildren: map[string][]string{ + "gav://org.example:m1:1.0": {"gav://org.ow2.asm:asm:9.8"}, + }, + wantChildClassifier: map[string]string{ + "gav://org.ow2.asm:asm:9.8": "tests", + }, + }, } for _, tc := range cases { @@ -822,6 +571,11 @@ func TestInjectPluginDeps(t *testing.T) { var childIDs []string for _, child := range root.Nodes { childIDs = append(childIDs, child.Id) + if want, ok := tc.wantChildClassifier[child.Id]; ok { + if assert.NotNilf(t, child.Classifier, "classifier for %s on root %s", child.Id, root.Id) { + assert.Equalf(t, want, *child.Classifier, "classifier for %s on root %s", child.Id, root.Id) + } + } } assert.ElementsMatch(t, tc.wantRootChildren[root.Id], childIDs, "children attached to module root %s", root.Id) @@ -829,29 +583,3 @@ func TestInjectPluginDeps(t *testing.T) { }) } } - -// TestTailStringValidUTF8 guards against splitting a multibyte rune mid-sequence. -// Without the rune-boundary nudge, byte slicing "xあy" with n=3 yields the -// continuation bytes "\x81\x82y" — invalid UTF-8. -func TestTailStringValidUTF8(t *testing.T) { - t.Parallel() - cases := []struct { - name string - in string - n int - want string - }{ - {"shorter than n returns full string", "abc", 10, "abc"}, - {"pure ASCII tail", "abcdefghij", 4, "...ghij"}, - {"multibyte cut mid-rune produces valid UTF-8 (reviewer's repro)", "xあy", 3, "...y"}, - {"multibyte cut on rune boundary is preserved", "xあy", 4, "...あy"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got := tailString(tc.in, tc.n) - assert.Equal(t, tc.want, got) - assert.True(t, utf8.ValidString(got), "result must be valid UTF-8, got %q", got) - }) - } -}