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 faf6a68b3..05ba99a71 100644 --- a/cli/scancommands.go +++ b/cli/scancommands.go @@ -755,6 +755,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 11cf8969d..4aaba47a6 100644 --- a/commands/curation/curationaudit.go +++ b/commands/curation/curationaudit.go @@ -235,6 +235,7 @@ type CurationAuditCommand struct { parallelRequests int dockerImageName string includeCachedPackages bool + mvnIncludePluginDeps bool audit.AuditParamsInterface } @@ -285,6 +286,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 { @@ -499,9 +505,10 @@ func (ca *CurationAuditCommand) getBuildInfoParamsByTech() (technologies.BuildIn Args: ca.Args(), InstallCommandArgs: ca.InstallCommandArgs(), // Curation params - IsCurationCmd: true, - ParallelRequests: ca.parallelRequests, - OutputFormat: ca.OutputFormat(), + IsCurationCmd: true, + MvnIncludePluginDeps: ca.mvnIncludePluginDeps, + ParallelRequests: ca.parallelRequests, + OutputFormat: ca.OutputFormat(), // Java params IsMavenDepTreeInstalled: true, UseWrapper: ca.UseWrapper(), diff --git a/commands/curation/curationaudit_test.go b/commands/curation/curationaudit_test.go index 6fb44398b..0d0b1c58d 100644 --- a/commands/curation/curationaudit_test.go +++ b/commands/curation/curationaudit_test.go @@ -568,6 +568,7 @@ func createCurationCmdAndRun(tt testCase) (cmdResults map[string]*CurationReport curationCmd.SetInsecureTls(true) curationCmd.SetIgnoreConfigFile(tt.shouldIgnoreConfigFile) curationCmd.SetInsecureTls(tt.allowInsecureTls) + curationCmd.SetMvnIncludePluginDeps(tt.mvnIncludePluginDeps) cmdResults = map[string]*CurationReport{} err = curationCmd.doCurateAudit(cmdResults) return @@ -596,6 +597,15 @@ func validateCurationResults(t *testing.T, testCase testCase, results map[string result.totalNumberOfPackages = 0 } } + // Cases that exercise Maven plugin-dep injection pull in a plugin's full transitive + // closure (e.g. maven-jar-plugin -> maven-archiver -> plexus-utils ...), which varies + // across plugin/Maven versions. Suppress the count assertion when requested. + if testCase.skipPackageCount { + for key := range results { + result := results[key] + result.totalNumberOfPackages = 0 + } + } assert.Equal(t, testCase.expectedResp, results) for _, requestDone := range testCase.expectedRequest { assert.True(t, requestDone) @@ -623,6 +633,13 @@ type testCase struct { tech techutils.Technology createServerWithoutCreds bool allowInsecureTls bool + // mvnIncludePluginDeps wires the --mvn-include-plugin-deps CLI flag into the curation + // audit command so the test exercises Maven build-plugin transitive dep collection. + mvnIncludePluginDeps bool + // skipPackageCount tells validateCurationResults to ignore totalNumberOfPackages. + // Use for cases where the count depends on a Maven plugin's transitive closure + // (e.g. maven-jar-plugin) and would otherwise be brittle across Maven/plugin versions. + skipPackageCount bool } func (tc testCase) getPathToTests() string { @@ -876,6 +893,70 @@ func getTestCasesForDoCurationAudit() []testCase { }, allowInsecureTls: true, }, + { + // Regression coverage for --mvn-include-plugin-deps. The customer scenario was a + // build that downloaded a curated artifact only via a Maven build-plugin's transitive + // closure; `jf ca` would report "0 blocked" because mvn dependency:tree never sees + // plugin deps. The test pom pins maven-jar-plugin to 3.4.1, whose fixed transitive + // closure includes org.ow2.asm:asm:9.8 (via plexus-archiver:4.9.2). The mock server + // blocks that exact jar URL. With the flag on, the curation audit must resolve plugin + // deps, inject asm into the tree, and surface it as blocked. + name: "maven tree - one blocked plugin dependency", + tech: techutils.Maven, + pathToProject: filepath.Join("projects", "package-managers", "maven", "maven-curation-plugin-deps"), + pathToTest: "test", + pathToPreTest: "pretest", + preTestExec: "mvn", + funcToGetGoals: func(t *testing.T) []string { + // Curation cache is keyed off the project directory — compute it from the + // test/ dir (where the real test will run) so pretest writes into the same + // folder that the test phase reads. Mirrors the maven-curation case above. + cleanUpTestDirChange := testUtils.ChangeWDWithCallback(t, filepath.Join("..", "test")) + 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. + return []string{ + "com.jfrog:maven-dep-tree:" + java.GetMavenDepTreeVersion() + ":tree", + "-DdepsTreeOutputFile=output", + "-Dmaven.repo.local=" + curationCache, + "dependency:resolve-plugins", + "help:effective-pom", + } + }, + mvnIncludePluginDeps: true, + // The full plugin closure depends on the runner's ambient Maven plugin versions; + // only asm:9.8 (pinned via maven-jar-plugin:3.4.1) is deterministic, so we assert + // just that blocked package and skip the non-deterministic total count. + skipPackageCount: true, + requestToFail: map[string]bool{ + "/maven-remote/org/ow2/asm/asm/9.8/asm-9.8.jar": false, + }, + expectedResp: map[string]*CurationReport{ + "test:plugin-dep-app:1.0.0": {packagesStatus: []*PackageStatus{ + { + Action: "blocked", + ParentVersion: "9.8", + ParentName: "org.ow2.asm:asm", + BlockedPackageUrl: "/maven-remote/org/ow2/asm/asm/9.8/asm-9.8.jar", + PackageName: "org.ow2.asm:asm", + PackageVersion: "9.8", + BlockingReason: "Policy violations", + PkgType: "maven", + DepRelation: "direct", + Policy: []Policy{ + { + Policy: "pol1", + Condition: "cond1", + }, + }, + }, + }}, + }, + }, { name: "maven tree - one blocked package", tech: techutils.Maven, 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 e710debd8..cdf6a0a4f 100644 --- a/sca/bom/buildinfo/technologies/common.go +++ b/sca/bom/buildinfo/technologies/common.go @@ -49,8 +49,9 @@ type BuildInfoBomGeneratorParams struct { Args []string InstallCommandArgs []string // Curation params - IsCurationCmd bool - ParallelRequests int + IsCurationCmd bool + MvnIncludePluginDeps bool + ParallelRequests int // OutputFormat is the --format flag value forwarded from the curation command. // The zero value is treated as outFormat.Table by all renderers. // Set by curation commands only; generic audit/scan commands leave this unset. 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..8adbb3c8a 100644 --- a/sca/bom/buildinfo/technologies/java/mvn.go +++ b/sca/bom/buildinfo/technologies/java/mvn.go @@ -3,15 +3,19 @@ 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" @@ -51,8 +55,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 +68,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 +90,340 @@ 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 { + injectPluginDeps(uniqueDeps, dependencyTree, manager.resolvePluginDeps()) + } return } +// injectPluginDeps adds plugin deps to uniqueDeps and fans them out to every module root. +// Split out so the dedup guard and fan-out are unit-testable without spawning Maven. +func injectPluginDeps(uniqueDeps map[string]*xray.DepTreeNode, dependencyTree []*xrayUtils.GraphNode, pluginDeps map[string]*xray.DepTreeNode) { + for id, node := range pluginDeps { + gavID := GavPackageTypeIdentifier + id + if _, exists := uniqueDeps[gavID]; exists { + continue + } + 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) + } + } 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. // 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..2ea7667ec 100644 --- a/sca/bom/buildinfo/technologies/java/mvn_test.go +++ b/sca/bom/buildinfo/technologies/java/mvn_test.go @@ -5,13 +5,16 @@ import ( "path/filepath" "strings" "testing" + "unicode/utf8" "github.com/jfrog/build-info-go/utils" "github.com/jfrog/jfrog-cli-core/v2/utils/config" coreTests "github.com/jfrog/jfrog-cli-core/v2/utils/tests" "github.com/jfrog/jfrog-cli-security/sca/bom/buildinfo/technologies" + "github.com/jfrog/jfrog-cli-security/utils/xray" "github.com/jfrog/jfrog-client-go/utils/io/fileutils" "github.com/jfrog/jfrog-client-go/utils/tests" + xrayUtils "github.com/jfrog/jfrog-client-go/xray/services/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/exp/maps" @@ -439,7 +442,280 @@ 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"} params := &DepTreeParams{ UseWrapper: true, @@ -449,6 +725,7 @@ func TestNewMavenDepTreeManagerPreservesAllParams(t *testing.T) { IsCurationCmd: true, CurationCacheFolder: "/tmp/cache", UseIncludedBuilds: true, + MvnIncludePluginDeps: true, } manager := NewMavenDepTreeManager(params, Tree) @@ -462,4 +739,119 @@ func TestNewMavenDepTreeManagerPreservesAllParams(t *testing.T) { assert.True(t, manager.isCurationCmd) assert.Equal(t, "/tmp/cache", manager.curationCacheFolder) assert.Equal(t, Tree, manager.cmdName) + assert.True(t, manager.mvnIncludePluginDeps, "MvnIncludePluginDeps must be propagated from params into the manager") +} + +// TestInjectPluginDeps locks in the dedup guard and the module-root fan-out +// of plugin-dep injection without spawning Maven. +func TestInjectPluginDeps(t *testing.T) { + t.Parallel() + jarType := func() *[]string { t := []string{"jar"}; return &t } + + 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: "empty plugin deps is a no-op", + uniqueDeps: map[string]*xray.DepTreeNode{"gav://g:a:1.0": {Types: jarType()}}, + dependencyTree: []*xrayUtils.GraphNode{{Id: "gav://org.example:m1:1.0"}}, + pluginDeps: nil, + wantUniqueDeps: []string{"gav://g:a:1.0"}, + wantRootChildren: map[string][]string{ + "gav://org.example:m1:1.0": nil, + }, + }, + { + name: "duplicate GAV is not re-added and existing entry is preserved", + uniqueDeps: map[string]*xray.DepTreeNode{ + "gav://org.codehaus.plexus:plexus-utils:4.0.2": existing, + }, + dependencyTree: []*xrayUtils.GraphNode{{Id: "gav://org.example:m1:1.0"}}, + pluginDeps: map[string]*xray.DepTreeNode{ + "org.codehaus.plexus:plexus-utils:4.0.2": {Types: jarType()}, + }, + wantUniqueDeps: []string{"gav://org.codehaus.plexus:plexus-utils:4.0.2"}, + wantRootChildren: map[string][]string{ + "gav://org.example:m1:1.0": nil, + }, + wantExistingKept: true, + }, + { + name: "new plugin dep is attached to every module root", + uniqueDeps: map[string]*xray.DepTreeNode{}, + dependencyTree: []*xrayUtils.GraphNode{ + {Id: "gav://org.example:m1:1.0"}, + {Id: "gav://org.example:m2:1.0"}, + }, + pluginDeps: map[string]*xray.DepTreeNode{ + "commons-io:commons-io:2.11.0": {Types: jarType()}, + }, + wantUniqueDeps: []string{"gav://commons-io:commons-io:2.11.0"}, + wantRootChildren: map[string][]string{ + "gav://org.example:m1:1.0": {"gav://commons-io:commons-io:2.11.0"}, + "gav://org.example:m2:1.0": {"gav://commons-io:commons-io:2.11.0"}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + injectPluginDeps(tc.uniqueDeps, tc.dependencyTree, tc.pluginDeps) + + gotKeys := make([]string, 0, len(tc.uniqueDeps)) + for k := range tc.uniqueDeps { + gotKeys = append(gotKeys, k) + } + assert.ElementsMatch(t, tc.wantUniqueDeps, gotKeys, "uniqueDeps key set") + + if tc.wantExistingKept { + assert.Samef(t, existing, tc.uniqueDeps["gav://org.codehaus.plexus:plexus-utils:4.0.2"], + "dedup guard must not overwrite the existing DepTreeNode") + } + + for _, root := range tc.dependencyTree { + var childIDs []string + for _, child := range root.Nodes { + childIDs = append(childIDs, child.Id) + } + assert.ElementsMatch(t, tc.wantRootChildren[root.Id], childIDs, + "children attached to module root %s", root.Id) + } + }) + } +} + +// 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) + }) + } } diff --git a/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/pretest/pom.xml b/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/pretest/pom.xml new file mode 100644 index 000000000..b8dada308 --- /dev/null +++ b/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/pretest/pom.xml @@ -0,0 +1,35 @@ + + 4.0.0 + test + plugin-dep-app + jar + 1.0.0 + plugin-dep-app + + + + + org.webjars + lodash + 4.17.21 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + + diff --git a/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/test/.jfrog/projects/maven.yaml b/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/test/.jfrog/projects/maven.yaml new file mode 100644 index 000000000..9f3254976 --- /dev/null +++ b/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/test/.jfrog/projects/maven.yaml @@ -0,0 +1,6 @@ +version: 1 +type: maven +resolver: + serverId: test + snapshotRepo: maven-remote + releaseRepo: maven-remote diff --git a/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/test/pom.xml b/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/test/pom.xml new file mode 100644 index 000000000..a48c114ce --- /dev/null +++ b/tests/testdata/projects/package-managers/maven/maven-curation-plugin-deps/test/pom.xml @@ -0,0 +1,36 @@ + + 4.0.0 + test + plugin-dep-app + jar + 1.0.0 + plugin-dep-app + + + + + org.webjars + lodash + 4.17.21 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.1 + + + +