diff --git a/cli/docs/flags.go b/cli/docs/flags.go
index 6a7128b74..d4483ed53 100644
--- a/cli/docs/flags.go
+++ b/cli/docs/flags.go
@@ -351,7 +351,7 @@ var flagsMap = map[string]components.Flag{
StaticSca: components.NewBoolFlag(StaticSca, "Set to true to use the new SCA engine which is based on lock files.", components.SetHiddenBoolFlag()),
UploadRtRepoPath: components.NewStringFlag(UploadRtRepoPath, fmt.Sprintf("Artifactory repository name or path to upload the scan results to. If no name or path are provided, a local generic repository will be created which will automatically be indexed by Xray. only relevant when using --%s", StaticSca), components.WithStrDefaultValue("cli-scan-results"), components.SetHiddenStrFlag()),
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."),
+ SolutionPath: components.NewStringFlag(SolutionPath, "Path to the .NET solution file (.sln or .slnx) 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 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."),
diff --git a/go.mod b/go.mod
index 727ee842b..614487edb 100644
--- a/go.mod
+++ b/go.mod
@@ -14,8 +14,8 @@ require (
github.com/gookit/color v1.6.1
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.6.3
- github.com/jfrog/build-info-go v1.13.1-0.20260728083052-16a97012811d
- github.com/jfrog/froggit-go v1.23.1
+ github.com/jfrog/build-info-go v1.13.1-0.20260728131356-58a6ccca0214
+ github.com/jfrog/froggit-go v1.23.1
github.com/jfrog/gofrog v1.7.6
github.com/jfrog/jfrog-apps-config v1.0.1
github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260728121041-2227ac7420a0
diff --git a/go.sum b/go.sum
index ff78e6031..87b16f91c 100644
--- a/go.sum
+++ b/go.sum
@@ -161,8 +161,8 @@ github.com/jedib0t/go-pretty/v6 v6.8.3 h1:yVSk5aemoYHCvcrtqyXklwqcgHQIQzmy/oUzFl
github.com/jedib0t/go-pretty/v6 v6.8.3/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/jfrog/archiver/v3 v3.6.3 h1:hkAmPjBw393tPmQ07JknLNWFNZjXdy2xFEnOW9wwOxI=
github.com/jfrog/archiver/v3 v3.6.3/go.mod h1:5V9l+Fte30Y4qe9dUOAd3yNTf8lmtVNuhKNrvI8PMhg=
-github.com/jfrog/build-info-go v1.13.1-0.20260728083052-16a97012811d h1:Yqbx+/9cIiZJmpyIkWDyC9Qjh4OaVeGWutADFaUzHr0=
-github.com/jfrog/build-info-go v1.13.1-0.20260728083052-16a97012811d/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE=
+github.com/jfrog/build-info-go v1.13.1-0.20260728131356-58a6ccca0214 h1:A5KHxN5VKuvvL7aIIn0GwDPGHuMclkLuBov+ZWbEcyw=
+github.com/jfrog/build-info-go v1.13.1-0.20260728131356-58a6ccca0214/go.mod h1:CYRUCvLKfyARjoJXLWAxce1qNUxTEtbRKAARkV42vpE=
github.com/jfrog/froggit-go v1.23.1 h1:4wmaHeuptxVINbovMaeITzVhi3+VQoc/FFIjF4axzu0=
github.com/jfrog/froggit-go v1.23.1/go.mod h1:wRDryqyp3oe+eHgME2mpnEQmO8XBECIPagFwj0nHmdI=
github.com/jfrog/gofrog v1.7.6 h1:QmfAiRzVyaI7JYGsB7cxfAJePAZTzFz0gRWZSE27c6s=
diff --git a/sca/bom/buildinfo/technologies/nuget/nuget.go b/sca/bom/buildinfo/technologies/nuget/nuget.go
index 1b7f2d70c..749f8d375 100644
--- a/sca/bom/buildinfo/technologies/nuget/nuget.go
+++ b/sca/bom/buildinfo/technologies/nuget/nuget.go
@@ -46,7 +46,14 @@ func BuildDependencyTree(params technologies.BuildInfoBomGeneratorParams) (depen
if err != nil {
return
}
- sol, err := solution.Load(wd, "", params.ExclusionPattern, log.Logger)
+ if p := params.SolutionFilePath; p != "" {
+ if info, statErr := os.Stat(filepath.Join(wd, filepath.Base(p))); statErr != nil || info.IsDir() {
+ err = fmt.Errorf("--solution-path %q: no solution file named %q in %s "+
+ "(the flag is resolved relative to the scanned directory)", p, filepath.Base(p), wd)
+ return
+ }
+ }
+ sol, err := solution.Load(wd, solutionFileName(params.SolutionFilePath), params.ExclusionPattern, log.Logger)
if err != nil && !strings.Contains(err.Error(), globalPackagesNotFoundErrorMessage) {
// In older NuGet projects that utilize NuGet Cli and package.config, if the project is not installed, the solution.Load function raises an error because it cannot find global package paths.
// This issue is resolved by executing the 'nuget restore' command followed by running solution.Load again. Therefore, in this scenario, we need to proceed with this process.
@@ -158,10 +165,21 @@ func runDotnetRestoreAndLoadSolution(params technologies.BuildInfoBomGeneratorPa
if err != nil {
return
}
- sol, err = solution.Load(tmpWd, "", exclusionPattern, log.Logger)
+ sol, err = solution.Load(tmpWd, solutionFileName(params.SolutionFilePath), exclusionPattern, log.Logger)
return
}
+// solutionFileName returns the base file name of the solution file explicitly provided via the
+// SolutionFilePath param (e.g. the '--solution-path' flag), or an empty string if none was
+// provided. An empty string tells solution.Load to auto-discover '.sln'/'.slnx' files under the
+// given directory, which is ambiguous when more than one solution file is present.
+func solutionFileName(solutionFilePath string) string {
+ if solutionFilePath == "" {
+ return ""
+ }
+ return filepath.Base(solutionFilePath)
+}
+
// Detects if the project is utilizing either .NET CLI or NuGet CLI, prioritizing .NET CLI.
// Note: For multi-module projects, only one of these tools can be identified and will be uniformly applied across all modules.
func getProjectToolName(wd string) (toolName string, err error) {
@@ -268,10 +286,9 @@ func runDotnetRestore(wd string, params technologies.BuildInfoBomGeneratorParams
}
// Check for solution file path from JF CA arguments for specific solution when we have more than one solution file
- if params.SolutionFilePath != "" {
- solutionFileName := filepath.Base(params.SolutionFilePath)
- completeCommandArgs = append(completeCommandArgs, solutionFileName)
- log.Info(fmt.Sprintf("Using solution file: %s", solutionFileName))
+ if slnFile := solutionFileName(params.SolutionFilePath); slnFile != "" {
+ completeCommandArgs = append(completeCommandArgs, slnFile)
+ log.Info(fmt.Sprintf("Using solution file: %s", slnFile))
}
// We include the flag that allows resolution from an Artifactory server, if it exists.
diff --git a/sca/bom/buildinfo/technologies/nuget/nuget_test.go b/sca/bom/buildinfo/technologies/nuget/nuget_test.go
index f4c9ef43c..415d0dd00 100644
--- a/sca/bom/buildinfo/technologies/nuget/nuget_test.go
+++ b/sca/bom/buildinfo/technologies/nuget/nuget_test.go
@@ -17,6 +17,7 @@ import (
"github.com/jfrog/build-info-go/entities"
"github.com/jfrog/jfrog-cli-core/v2/utils/tests"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
securityTestUtils "github.com/jfrog/jfrog-cli-security/tests/utils"
)
@@ -242,6 +243,11 @@ func TestSolutionFilePathParameter(t *testing.T) {
solutionFilePath: "/path/to/my-solution.sln",
expectedFileName: "my-solution.sln",
},
+ {
+ name: "slnx solution file path from params",
+ solutionFilePath: "/path/to/my-solution.slnx",
+ expectedFileName: "my-solution.slnx",
+ },
{
name: "no solution file path",
solutionFilePath: "",
@@ -251,21 +257,7 @@ func TestSolutionFilePathParameter(t *testing.T) {
for _, test := range testCases {
t.Run(test.name, func(t *testing.T) {
- params := technologies.BuildInfoBomGeneratorParams{
- SolutionFilePath: test.solutionFilePath,
- }
-
- // Get the solution file path using the same logic as runDotnetRestore
- var solutionFilePath string
- if params.SolutionFilePath != "" {
- solutionFilePath = params.SolutionFilePath
- }
- var solutionFileName string
- if solutionFilePath != "" {
- solutionFileName = filepath.Base(solutionFilePath)
- }
-
- assert.Equal(t, test.expectedFileName, solutionFileName)
+ assert.Equal(t, test.expectedFileName, solutionFileName(test.solutionFilePath))
})
}
}
@@ -286,3 +278,99 @@ func TestRunDotnetRestoreWithRealSolutionFile(t *testing.T) {
assert.NotContains(t, err.Error(), "this folder contains more than one project or solution file")
}
}
+
+// TestBuildDependencyTreeWithSlnxSolution proves a '.slnx'-only project (no '.sln' fallback)
+// resolves real dependencies via BuildDependencyTree, the same entry point 'jf ca' uses.
+func TestBuildDependencyTreeWithSlnxSolution(t *testing.T) {
+ _, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "nuget", "slnx-single"))
+ defer cleanUp()
+
+ params := technologies.BuildInfoBomGeneratorParams{}
+ dependencyTrees, uniqueDeps, err := BuildDependencyTree(params)
+ require.NoError(t, err)
+ assert.NotEmpty(t, dependencyTrees, "expected at least one dependency tree to be built from the '.slnx' solution")
+ assert.NotEmpty(t, uniqueDeps, "expected at least one dependency to be resolved from the '.slnx' solution's ClassLibrary1 project")
+}
+
+// TestBuildDependencyTreeWithExplicitSlnxSolutionPath proves passing a '.slnx' file explicitly
+// via '--solution-path' resolves real dependencies via BuildDependencyTree, not just auto-discovery.
+func TestBuildDependencyTreeWithExplicitSlnxSolutionPath(t *testing.T) {
+ workDir, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "nuget", "slnx-single"))
+ defer cleanUp()
+
+ params := technologies.BuildInfoBomGeneratorParams{
+ SolutionFilePath: filepath.Join(workDir, "TestSolution.slnx"),
+ }
+ dependencyTrees, uniqueDeps, err := BuildDependencyTree(params)
+ require.NoError(t, err)
+ assert.NotEmpty(t, dependencyTrees, "expected at least one dependency tree to be built from the '.slnx' solution via --solution-path")
+ assert.NotEmpty(t, uniqueDeps, "expected at least one dependency to be resolved from the '.slnx' solution's ClassLibrary1 project via --solution-path")
+}
+
+// TestSolutionFilePathDisambiguatesMultipleSlnFiles verifies --solution-path correctly picks
+// one '.sln' out of several in the same directory, instead of hitting dotnet's "more than one
+// project or solution file" ambiguity error.
+//
+// Uses its own "multi-solutions" fixture (not "multi") because TestSkipBuildDepTreeWhenInstallForbidden
+// relies on "multi" having exactly one '.sln' for its auto-discovery/no-solution-path case.
+func TestSolutionFilePathDisambiguatesMultipleSlnFiles(t *testing.T) {
+ workDir, cleanUp := technologies.CreateTestWorkspace(t, filepath.Join("projects", "package-managers", "nuget", "multi-solutions"))
+ defer cleanUp()
+
+ // Sanity check: the workspace must actually contain more than one '.sln' file, otherwise
+ // this test wouldn't be exercising disambiguation at all.
+ slnFiles, err := filepath.Glob(filepath.Join(workDir, "*.sln"))
+ require.NoError(t, err)
+ require.Len(t, slnFiles, 2, "expected exactly 2 '.sln' files in the fixture to test disambiguation")
+
+ toolType := bidotnet.ConvertNameToToolType("dotnet")
+ for _, slnName := range []string{"TestSolution.sln", "TestAppOnly.sln"} {
+ t.Run(slnName, func(t *testing.T) {
+ params := technologies.BuildInfoBomGeneratorParams{
+ SolutionFilePath: filepath.Join(workDir, slnName),
+ }
+ err := runDotnetRestore(workDir, params, toolType, []string{})
+ assert.NoError(t, err)
+ })
+ }
+}
+
+// TestSolutionFilePathValidation covers the '--solution-path' pre-flight check in
+// BuildDependencyTree. The check runs before any solution load or 'restore', so these
+// cases never invoke the dotnet CLI.
+func TestSolutionFilePathValidation(t *testing.T) {
+ testCases := []struct {
+ name string
+ solutionFilePath string
+ setup func(t *testing.T, dir string)
+ }{
+ {
+ name: "base name not present in scanned directory",
+ solutionFilePath: filepath.Join("sub", "Missing.sln"),
+ },
+ {
+ name: "path resolves to a directory, not a solution file",
+ solutionFilePath: filepath.Join("sub", "NotASolution"),
+ setup: func(t *testing.T, dir string) {
+ require.NoError(t, os.Mkdir(filepath.Join(dir, "NotASolution"), 0o750))
+ },
+ },
+ }
+ for _, test := range testCases {
+ t.Run(test.name, func(t *testing.T) {
+ dir := t.TempDir()
+ if test.setup != nil {
+ test.setup(t, dir)
+ }
+ t.Chdir(dir)
+ dependencyTrees, uniqueDeps, err := BuildDependencyTree(technologies.BuildInfoBomGeneratorParams{
+ SolutionFilePath: test.solutionFilePath,
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "--solution-path", "the error must name the flag the user passed")
+ assert.Contains(t, err.Error(), filepath.Base(test.solutionFilePath))
+ assert.Nil(t, dependencyTrees)
+ assert.Nil(t, uniqueDeps)
+ })
+ }
+}
diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/ClassLibrary1/ClassLibrary1.csproj b/tests/testdata/projects/package-managers/nuget/multi-solutions/ClassLibrary1/ClassLibrary1.csproj
new file mode 100644
index 000000000..6e86919b4
--- /dev/null
+++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/ClassLibrary1/ClassLibrary1.csproj
@@ -0,0 +1,11 @@
+
+
+
+ netstandard2.0
+
+
+
+
+
+
+
diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/TestApp1/TestApp1.csproj b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestApp1/TestApp1.csproj
new file mode 100644
index 000000000..d5d1bfea9
--- /dev/null
+++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestApp1/TestApp1.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ netcoreapp3.1
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/TestAppOnly.sln b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestAppOnly.sln
new file mode 100644
index 000000000..30b792e66
--- /dev/null
+++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestAppOnly.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.32106.194
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApp1", "TestApp1\TestApp1.csproj", "{757C1D5A-BAA9-453F-B612-A0571390976B}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {E1A2B3C4-D5E6-4F70-8A9B-1C2D3E4F5A6B}
+ EndGlobalSection
+EndGlobal
diff --git a/tests/testdata/projects/package-managers/nuget/multi-solutions/TestSolution.sln b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestSolution.sln
new file mode 100644
index 000000000..1f834792b
--- /dev/null
+++ b/tests/testdata/projects/package-managers/nuget/multi-solutions/TestSolution.sln
@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.32106.194
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestApp1", "TestApp1\TestApp1.csproj", "{757C1D5A-BAA9-453F-B612-A0571390976B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClassLibrary1", "ClassLibrary1\ClassLibrary1.csproj", "{D9C89660-69F8-46E2-B9C4-78E9B505E8B1}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {757C1D5A-BAA9-453F-B612-A0571390976B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D9C89660-69F8-46E2-B9C4-78E9B505E8B1}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {D8547989-C4F7-4FCC-95BB-F7478F7D2484}
+ EndGlobalSection
+EndGlobal
diff --git a/tests/testdata/projects/package-managers/nuget/slnx-single/ClassLibrary1/ClassLibrary1.csproj b/tests/testdata/projects/package-managers/nuget/slnx-single/ClassLibrary1/ClassLibrary1.csproj
new file mode 100644
index 000000000..7e0ce58ae
--- /dev/null
+++ b/tests/testdata/projects/package-managers/nuget/slnx-single/ClassLibrary1/ClassLibrary1.csproj
@@ -0,0 +1,9 @@
+
+
+
+ netstandard2.0
+
+
+
+
+
diff --git a/tests/testdata/projects/package-managers/nuget/slnx-single/TestSolution.slnx b/tests/testdata/projects/package-managers/nuget/slnx-single/TestSolution.slnx
new file mode 100644
index 000000000..83a293b76
--- /dev/null
+++ b/tests/testdata/projects/package-managers/nuget/slnx-single/TestSolution.slnx
@@ -0,0 +1,3 @@
+
+
+
diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go
index f20b3e89d..577b5977c 100644
--- a/utils/techutils/techutils.go
+++ b/utils/techutils/techutils.go
@@ -248,8 +248,8 @@ var technologiesData = map[Technology]TechData{
},
Nuget: {
formal: "NuGet",
- indicators: []string{".sln", ".csproj"},
- packageDescriptors: []string{".sln", ".csproj"},
+ indicators: []string{".sln", ".slnx", ".csproj"},
+ packageDescriptors: []string{".sln", ".slnx", ".csproj"},
// .NET CLI is used for NuGet projects
execCommand: "dotnet",
packageInstallationCommand: "add",
@@ -260,8 +260,8 @@ var technologiesData = map[Technology]TechData{
},
Dotnet: {
formal: ".NET",
- indicators: []string{".sln", ".csproj"},
- packageDescriptors: []string{".sln", ".csproj"},
+ indicators: []string{".sln", ".slnx", ".csproj"},
+ packageDescriptors: []string{".sln", ".slnx", ".csproj"},
projectType: project.Dotnet,
language: CSharp,
},
diff --git a/utils/techutils/techutils_test.go b/utils/techutils/techutils_test.go
index 22857bd68..9fc9cc612 100644
--- a/utils/techutils/techutils_test.go
+++ b/utils/techutils/techutils_test.go
@@ -148,6 +148,17 @@ func TestMapFilesToRelevantWorkingDirectories(t *testing.T) {
},
expectedExcluded: noExclude,
},
+ {
+ // A directory with only a '.slnx' file (no '.sln', no '.csproj') must still be
+ // detected as a NuGet/.NET working directory.
+ name: "nugetSlnxOnlyTest",
+ paths: []string{filepath.Join("dir", "project.slnx"), filepath.Join("dir", "file")},
+ requestedDescriptors: noRequest,
+ expectedWorkingDir: map[string][]string{
+ "dir": {filepath.Join("dir", "project.slnx")},
+ },
+ expectedExcluded: noExclude,
+ },
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {