diff --git a/remediation/sca/packageupdaters/nugetpackageupdater.go b/remediation/sca/packageupdaters/nugetpackageupdater.go index a1a170b5d..594fbc874 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strings" "github.com/jfrog/jfrog-client-go/utils/log" @@ -27,8 +28,20 @@ const ( // literal fixed version rather than resolved and updated at its property definition. That's a // deliberate simplification, and a real behavior change versus how MSBuild itself would resolve // it; Maven's updater handles the analogous ${property} case by updating the definition instead. - nugetVersionAttrPattern = `(?is)(\bVersion\s*=\s*["'])[^"']*(["'])` - nugetVersionElementPattern = `(?is)()[^<]*()` + nugetVersionAttrPattern = `(?is)(\bVersion\s*=\s*["'])[^"']*(["'])` + nugetVersionElementPattern = `(?is)()[^<]*()` + nugetVersionOverrideAttrPattern = `(?is)(\bVersionOverride\s*=\s*["'])[^"']*(["'])` + nugetVersionOverrideElementPattern = `(?is)()[^<]*()` + + nugetPackageVersionElementPattern = `(?is)]*/>|]*[^/]>.*?` + nugetPackageVersionKeyAttrPattern = `(?i)\b(?:Include|Update)\s*=\s*["']%s["']` + nugetDirectoryPackagesPropsName = "Directory.Packages.props" + nugetDirectoryBuildPropsName = "Directory.Build.props" + nugetDirectoryBuildTargetsName = "Directory.Build.targets" + nugetImportProjectAttrPattern = `(?i)]*\bProject\s*=\s*["']([^"']+)["']` + nugetManageCpmFalsePattern = `(?is)\s*false\s*` + nugetManageCpmTruePattern = `(?is)\s*true\s*` + nugetMSBuildThisFileDirectoryMacro = "$(MSBuildThisFileDirectory)" nugetLockFileName = "packages.lock.json" nugetObjDirName = "obj" @@ -96,15 +109,57 @@ func (n *NugetPackageUpdater) UpdateDependency(fixDetails *FixDetails) error { } func collectProjectFilePaths(fixDetails *FixDetails) []string { + seen := make(map[string]struct{}) var paths []string + add := func(path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + seen[path] = struct{}{} + paths = append(paths, path) + } + for _, path := range GetVulnerabilityLocations(fixDetails, []string{}, []string{}) { - if hasNugetProjectFileSuffix(path) { - paths = append(paths, path) + switch { + case hasNugetProjectFileSuffix(path): + add(path) + case strings.EqualFold(filepath.Base(path), nugetLockFileName): + projectPath, err := findProjectFileInDir(filepath.Dir(path)) + if err != nil { + log.Debug(fmt.Sprintf("Failed to resolve project file next to '%s': %s", path, err.Error())) + continue + } + add(projectPath) } } return paths } +func findProjectFileInDir(dir string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", fmt.Errorf("failed to read directory %s: %w", dir, err) + } + var matches []string + for _, entry := range entries { + if !entry.IsDir() && hasNugetProjectFileSuffix(entry.Name()) { + matches = append(matches, filepath.Join(dir, entry.Name())) + } + } + if len(matches) == 0 { + return "", nil + } + if len(matches) > 1 { + sort.Strings(matches) + log.Debug(fmt.Sprintf("Skipping lock file in '%s': found multiple project files (%s)", dir, strings.Join(matches, ", "))) + return "", nil + } + return matches[0], nil +} + func hasNugetProjectFileSuffix(path string) bool { lowerPath := strings.ToLower(path) for _, suffix := range nugetProjectFileSuffixes { @@ -122,9 +177,12 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag return fmt.Errorf("failed to read %s: %w", projectFilePath, err) } - updatedProjectFile, err := updatePackageReferenceVersion(originalProjectFile, packageName, fixedVersion) - if err != nil { - return fmt.Errorf("%w in %s", err, projectFilePath) + updatedProjectFile, fixErr := updatePackageReferenceVersion(originalProjectFile, packageName, fixedVersion) + if isMixedInlineAndNonInline(fixErr) { + return fmt.Errorf("%w in %s", fixErr, projectFilePath) + } + if fixErr != nil { + return n.fixViaVersionSource(projectFilePath, packageName, fixedVersion, originalWd) } //#nosec G703 G306 -- projectFilePath from scan workflow; 0644 for VCS-tracked sources. @@ -132,15 +190,116 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag return fmt.Errorf("failed to write %s: %w", projectFilePath, err) } + return n.restoreLockFileAfterWrite(projectFilePath, projectFilePath, originalProjectFile, originalWd, packageName, fixedVersion) +} + +func (n *NugetPackageUpdater) fixViaVersionSource(projectFilePath, packageName, fixedVersion, originalWd string) error { + err := n.fixViaDirectoryPackagesProps(projectFilePath, packageName, fixedVersion, originalWd) + if !isUnsupportedNoInlineVersion(err) { + return err + } + return n.fixViaDirectoryBuildProps(projectFilePath, packageName, fixedVersion, originalWd) +} + +func isUnsupportedNoInlineVersion(err error) bool { + var unsupportedErr *ErrUnsupportedFix + return errors.As(err, &unsupportedErr) && unsupportedErr.ErrorType == NoInlineVersionFixNotSupported +} + +func isMixedInlineAndNonInline(err error) bool { + var unsupportedErr *ErrUnsupportedFix + return errors.As(err, &unsupportedErr) && unsupportedErr.ErrorType == MixedInlineAndNonInlineVersionFixNotSupported +} + +func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, packageName, fixedVersion, originalWd string) error { + unsupported := &ErrUnsupportedFix{ + PackageName: packageName, + FixedVersion: fixedVersion, + ErrorType: NoInlineVersionFixNotSupported, + } + + propsPath, originalProps, err := resolveDirectoryFile(filepath.Dir(projectFilePath), originalWd, nugetDirectoryPackagesPropsName) + if err != nil { + return fmt.Errorf("%w in %s", err, projectFilePath) + } + if propsPath == "" { + return unsupported + } + + absRepoRoot, err := filepath.Abs(originalWd) + if err != nil { + return fmt.Errorf("failed to resolve absolute path for %s: %w", originalWd, err) + } + + if isCentralPackageManagementDisabledInContext(projectFilePath, originalProps, absRepoRoot) { + return unsupported + } + + targetPath, originalContent, updatedContent, fixedAny, err := resolvePackageVersionUpdate(propsPath, originalProps, packageName, fixedVersion, absRepoRoot) + if err != nil { + return fmt.Errorf("%w in %s", err, projectFilePath) + } + if !fixedAny { + return unsupported + } + + //#nosec G703 G306 -- targetPath resolved from descriptor discovery in the scanned repository. + if err = os.WriteFile(targetPath, updatedContent, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", targetPath, err) + } + + return n.restoreLockFileAfterWrite(projectFilePath, targetPath, originalContent, originalWd, packageName, fixedVersion) +} + +func (n *NugetPackageUpdater) fixViaDirectoryBuildProps(projectFilePath, packageName, fixedVersion, originalWd string) error { + unsupported := &ErrUnsupportedFix{ + PackageName: packageName, + FixedVersion: fixedVersion, + ErrorType: NoInlineVersionFixNotSupported, + } + + absRepoRoot, err := filepath.Abs(originalWd) + if err != nil { + return fmt.Errorf("failed to resolve absolute path for %s: %w", originalWd, err) + } + + for _, fileName := range []string{nugetDirectoryBuildPropsName, nugetDirectoryBuildTargetsName} { + buildFilePath, buildFileContent, resolveErr := resolveDirectoryFile(filepath.Dir(projectFilePath), originalWd, fileName) + if resolveErr != nil { + return fmt.Errorf("%w in %s", resolveErr, projectFilePath) + } + if buildFilePath == "" { + continue + } + + for _, tryFix := range []versionFixFunc{tryFixPackageReference, tryFixPackageVersionEntry} { + targetPath, originalContent, updatedContent, fixedAny, searchErr := searchVersionUpdate(buildFilePath, buildFileContent, packageName, fixedVersion, absRepoRoot, tryFix, map[string]struct{}{}) + if searchErr != nil { + return fmt.Errorf("%w in %s", searchErr, projectFilePath) + } + if !fixedAny { + continue + } + + //#nosec G703 G306 -- targetPath resolved from descriptor discovery in the scanned repository. + if err = os.WriteFile(targetPath, updatedContent, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", targetPath, err) + } + return n.restoreLockFileAfterWrite(projectFilePath, targetPath, originalContent, originalWd, packageName, fixedVersion) + } + } + return unsupported +} + +func (n *NugetPackageUpdater) restoreLockFileAfterWrite(projectFilePath, writtenPath string, originalWritten []byte, originalWd, packageName, fixedVersion string) error { lockFilePath := filepath.Join(filepath.Dir(projectFilePath), nugetLockFileName) //#nosec G304 -- lockFilePath is derived from projectFilePath, itself from descriptor discovery. originalLockFile, err := os.ReadFile(lockFilePath) if err != nil { if os.IsNotExist(err) { - // No lock file for this project - nothing further to regenerate. return nil } - return rollbackProjectFile(projectFilePath, originalProjectFile, fmt.Errorf("failed to read %s: %w", lockFilePath, err)) + return rollbackProjectFile(writtenPath, originalWritten, fmt.Errorf("failed to read %s: %w", lockFilePath, err)) } absLockFilePath := lockFilePath @@ -159,7 +318,7 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag if err = n.runDotnetRestore(projectFilePath); err != nil { log.Warn(fmt.Sprintf("Failed to regenerate lock file after updating '%s' to version '%s': %s. Rolling back...", packageName, fixedVersion, err.Error())) - return rollbackProjectFileAndLock(projectFilePath, originalProjectFile, lockFilePath, originalLockFile, err) + return rollbackProjectFileAndLock(writtenPath, originalWritten, lockFilePath, originalLockFile, err) } return nil } @@ -227,6 +386,8 @@ func updatePackageReferenceVersion(content []byte, packageName, fixedVersion str keyAttr := regexp.MustCompile(fmt.Sprintf(nugetKeyAttrPattern, regexp.QuoteMeta(packageName))) versionAttr := regexp.MustCompile(nugetVersionAttrPattern) versionElement := regexp.MustCompile(nugetVersionElementPattern) + versionOverrideAttr := regexp.MustCompile(nugetVersionOverrideAttrPattern) + versionOverrideElement := regexp.MustCompile(nugetVersionOverrideElementPattern) var fixedAny, foundWithoutVersion bool updatedContent := element.ReplaceAllFunc(content, func(match []byte) []byte { @@ -240,12 +401,25 @@ func updatePackageReferenceVersion(content []byte, packageName, fixedVersion str case versionElement.Match(match): fixedAny = true return versionElement.ReplaceAll(match, []byte("${1}"+fixedVersion+"${2}")) + case versionOverrideAttr.Match(match): + fixedAny = true + return versionOverrideAttr.ReplaceAll(match, []byte("${1}"+fixedVersion+"${2}")) + case versionOverrideElement.Match(match): + fixedAny = true + return versionOverrideElement.ReplaceAll(match, []byte("${1}"+fixedVersion+"${2}")) default: foundWithoutVersion = true return match } }) + if foundWithoutVersion && fixedAny { + return nil, &ErrUnsupportedFix{ + PackageName: packageName, + FixedVersion: fixedVersion, + ErrorType: MixedInlineAndNonInlineVersionFixNotSupported, + } + } if foundWithoutVersion { return nil, &ErrUnsupportedFix{ PackageName: packageName, @@ -258,3 +432,181 @@ func updatePackageReferenceVersion(content []byte, packageName, fixedVersion str } return nil, fmt.Errorf("dependency %s not found", packageName) } + +func updatePackageVersionEntry(content []byte, packageName, fixedVersion string) ([]byte, bool) { + element := regexp.MustCompile(nugetPackageVersionElementPattern) + keyAttr := regexp.MustCompile(fmt.Sprintf(nugetPackageVersionKeyAttrPattern, regexp.QuoteMeta(packageName))) + versionAttr := regexp.MustCompile(nugetVersionAttrPattern) + versionElement := regexp.MustCompile(nugetVersionElementPattern) + + var fixedAny bool + updatedContent := element.ReplaceAllFunc(content, func(match []byte) []byte { + if !keyAttr.Match(match) { + return match + } + switch { + case versionAttr.Match(match): + fixedAny = true + return versionAttr.ReplaceAll(match, []byte("${1}"+fixedVersion+"${2}")) + case versionElement.Match(match): + fixedAny = true + return versionElement.ReplaceAll(match, []byte("${1}"+fixedVersion+"${2}")) + default: + return match + } + }) + return updatedContent, fixedAny +} + +type versionFixFunc func(content []byte, packageName, fixedVersion string) ([]byte, bool, error) + +func resolvePackageVersionUpdate(propsPath string, propsContent []byte, packageName, fixedVersion, repoRoot string) (string, []byte, []byte, bool, error) { + if isCentralPackageManagementDisabled(propsContent) { + return "", nil, nil, false, nil + } + return searchVersionUpdate(propsPath, propsContent, packageName, fixedVersion, repoRoot, tryFixPackageVersionEntry, map[string]struct{}{}) +} + +func isCentralPackageManagementDisabled(content []byte) bool { + return regexp.MustCompile(nugetManageCpmFalsePattern).Match(content) +} + +func isCentralPackageManagementEnabled(content []byte) bool { + return regexp.MustCompile(nugetManageCpmTruePattern).Match(content) +} + +func isCentralPackageManagementDisabledInContext(projectFilePath string, packagesProps []byte, repoRoot string) bool { + if isCentralPackageManagementDisabled(packagesProps) { + return true + } + if isCentralPackageManagementEnabled(packagesProps) { + return false + } + //#nosec G304 -- projectFilePath from descriptor discovery in the scanned repository. + if projectContent, err := os.ReadFile(projectFilePath); err == nil { + if isCentralPackageManagementDisabled(projectContent) { + return true + } + if isCentralPackageManagementEnabled(projectContent) { + return false + } + } + buildFilePath, buildFileContent, err := resolveDirectoryFile(filepath.Dir(projectFilePath), repoRoot, nugetDirectoryBuildPropsName) + if err != nil || buildFilePath == "" { + return false + } + return isCentralPackageManagementDisabled(buildFileContent) +} + +func tryFixPackageReference(content []byte, packageName, fixedVersion string) ([]byte, bool, error) { + updated, err := updatePackageReferenceVersion(content, packageName, fixedVersion) + if err == nil { + return updated, true, nil + } + if isMixedInlineAndNonInline(err) { + return nil, false, err + } + return nil, false, nil +} + +func tryFixPackageVersionEntry(content []byte, packageName, fixedVersion string) ([]byte, bool, error) { + updated, fixedAny := updatePackageVersionEntry(content, packageName, fixedVersion) + return updated, fixedAny, nil +} + +func searchVersionUpdate(path string, content []byte, packageName, fixedVersion, repoRoot string, tryFix versionFixFunc, visited map[string]struct{}) (string, []byte, []byte, bool, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", nil, nil, false, fmt.Errorf("failed to resolve absolute path for %s: %w", path, err) + } + if !isPathInsideRoot(repoRoot, absPath) { + return "", nil, nil, false, nil + } + if _, seen := visited[absPath]; seen { + return "", nil, nil, false, nil + } + visited[absPath] = struct{}{} + + updated, fixedAny, tryErr := tryFix(content, packageName, fixedVersion) + if tryErr != nil { + return "", nil, nil, false, tryErr + } + if fixedAny { + return absPath, content, updated, true, nil + } + + importAttr := regexp.MustCompile(nugetImportProjectAttrPattern) + for _, match := range importAttr.FindAllSubmatch(content, -1) { + projectRef := string(match[1]) + if strings.HasPrefix(projectRef, nugetMSBuildThisFileDirectoryMacro) { + suffix := filepath.FromSlash(strings.TrimPrefix(projectRef, nugetMSBuildThisFileDirectoryMacro)) + projectRef = filepath.Join(filepath.Dir(absPath), suffix) + } + if strings.Contains(projectRef, "$") { + continue + } + nextPath := projectRef + if !filepath.IsAbs(projectRef) { + nextPath = filepath.Join(filepath.Dir(absPath), filepath.FromSlash(projectRef)) + } + nextAbs, absErr := filepath.Abs(nextPath) + if absErr != nil || !isPathInsideRoot(repoRoot, nextAbs) { + continue + } + //#nosec G304 -- nextAbs is an Import Project path constrained to the scanned repository. + nextContent, readErr := os.ReadFile(nextAbs) + if readErr != nil { + if os.IsNotExist(readErr) { + continue + } + return "", nil, nil, false, fmt.Errorf("failed to read %s: %w", nextAbs, readErr) + } + foundPath, original, updatedImported, ok, searchErr := searchVersionUpdate(nextAbs, nextContent, packageName, fixedVersion, repoRoot, tryFix, visited) + if searchErr != nil || ok { + return foundPath, original, updatedImported, ok, searchErr + } + } + return "", nil, nil, false, nil +} + +func resolveDirectoryFile(startDir, repoRoot, fileName string) (path string, content []byte, err error) { + absRepoRoot, err := filepath.Abs(repoRoot) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve absolute path for %s: %w", repoRoot, err) + } + dir, err := filepath.Abs(startDir) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve absolute path for %s: %w", startDir, err) + } + + for { + if !isPathInsideRoot(absRepoRoot, dir) { + return "", nil, nil + } + candidate := filepath.Join(dir, fileName) + //#nosec G304 -- candidate is built from startDir/repoRoot, from descriptor discovery. + candidateContent, readErr := os.ReadFile(candidate) + if readErr == nil { + return candidate, candidateContent, nil + } + if !os.IsNotExist(readErr) { + return "", nil, fmt.Errorf("failed to read %s: %w", candidate, readErr) + } + if dir == absRepoRoot { + return "", nil, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", nil, nil + } + dir = parent + } +} + +func isPathInsideRoot(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))) +} diff --git a/remediation/sca/packageupdaters/nugetpackageupdater_test.go b/remediation/sca/packageupdaters/nugetpackageupdater_test.go index cff5c3f83..f61c5c8b9 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater_test.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater_test.go @@ -920,5 +920,1133 @@ func TestUpdatePackageReferenceVersionRejectsMixedInlineAndNonInline(t *testing. assert.Nil(t, updated) var unsupportedErr *ErrUnsupportedFix assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, MixedInlineAndNonInlineVersionFixNotSupported, unsupportedErr.ErrorType) +} + +func TestNugetUpdateDependencyResolvesSingleDirectoryPackagesProps(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(propsContent), `Include="Newtonsoft.Json" Version="13.0.1"`) + + csprojContent, err := os.ReadFile(filepath.Join("Cpm", "Proj", "Proj.csproj")) + assert.NoError(t, err) + assert.Contains(t, string(csprojContent), ``) + + lockContent, err := os.ReadFile(filepath.Join("Cpm", "Proj", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockContent), `"resolved":"13.0.1"`) +} + +func TestNugetUpdateDependencyResolvesProjectFileFromLockFileEvidence(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Proj", "packages.lock.json")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(propsContent), `Include="Newtonsoft.Json" Version="13.0.1"`) + + lockContent, err := os.ReadFile(filepath.Join("Cpm", "Proj", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockContent), `"resolved":"13.0.1"`) +} + +func TestNugetUpdateDependencyResolvesDirectoryBuildProps(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("BuildProps", "Proj", "packages.lock.json")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + buildPropsContent, err := os.ReadFile(filepath.Join("BuildProps", "Directory.Build.props")) + assert.NoError(t, err) + assert.Contains(t, string(buildPropsContent), `Include="Newtonsoft.Json" Version="13.0.1"`) + + csprojContent, err := os.ReadFile(filepath.Join("BuildProps", "Proj", "Proj.csproj")) + assert.NoError(t, err) + assert.NotContains(t, string(csprojContent), "Newtonsoft.Json") + + lockContent, err := os.ReadFile(filepath.Join("BuildProps", "Proj", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockContent), `"resolved":"13.0.1"`) +} + +func TestNugetUpdateDependencyVersionOverrideTakesPrecedenceOverCpm(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + originalProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "ProjWithOverride", "ProjWithOverride.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + csprojContent, err := os.ReadFile(filepath.Join("Cpm", "ProjWithOverride", "ProjWithOverride.csproj")) + assert.NoError(t, err) + assert.Contains(t, string(csprojContent), `VersionOverride="13.0.1"`) + assert.NotContains(t, string(csprojContent), `VersionOverride="11.0.1"`) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalProps, propsContent) + + lockContent, err := os.ReadFile(filepath.Join("Cpm", "ProjWithOverride", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockContent), `"resolved":"13.0.1"`) +} + +func TestNugetUpdateDependencyNestedDirectoryPackagesPropsClosestWins(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + originalRootProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Nested", "NestedProj", "NestedProj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + nestedProps, err := os.ReadFile(filepath.Join("Cpm", "Nested", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(nestedProps), `Include="Newtonsoft.Json" Version="13.0.1"`) + + rootProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalRootProps, rootProps) + + lockContent, err := os.ReadFile(filepath.Join("Cpm", "Nested", "NestedProj", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockContent), `"resolved":"13.0.1"`) +} + +func TestNugetUpdateDependencyVersionOverrideChildElementTakesPrecedenceOverCpm(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + csprojPath := filepath.Join("Cpm", "ProjWithOverride", "ProjWithOverride.csproj") + assert.NoError(t, os.WriteFile(csprojPath, []byte(` + + net8.0 + true + + + + 11.0.1 + + + +`), 0644)) + + originalProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: csprojPath}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + csprojContent, err := os.ReadFile(csprojPath) + assert.NoError(t, err) + assert.Contains(t, string(csprojContent), `13.0.1`) + assert.NotContains(t, string(csprojContent), `11.0.1`) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalProps, propsContent) +} + +func TestNugetUpdateDependencyDirectoryPackagesPropsWithoutMatchingEntry(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "1.0.0", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "NotInCpm.Package", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, NoInlineVersionFixNotSupported, unsupportedErr.ErrorType) +} + +func TestResolveDirectoryPackagesPropsDoesNotWalkOutsideRepoRoot(t *testing.T) { + integration.InitUnitTest(t) + + repoRoot := t.TempDir() + outsideRoot := t.TempDir() + projectDir := filepath.Join(outsideRoot, "app") + assert.NoError(t, os.MkdirAll(projectDir, 0755)) + outsideProps := filepath.Join(outsideRoot, "Directory.Packages.props") + assert.NoError(t, os.WriteFile(outsideProps, []byte(``), 0644)) + + path, content, err := resolveDirectoryFile(projectDir, repoRoot, nugetDirectoryPackagesPropsName) + assert.NoError(t, err) + assert.Empty(t, path) + assert.Empty(t, content) +} + +func TestNugetUpdateDependencyFollowsDirectoryPackagesPropsImport(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + importedDir := filepath.Join("Cpm", "Imported") + assert.NoError(t, os.MkdirAll(filepath.Join(importedDir, "Proj"), 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(importedDir, "Directory.Packages.props"), []byte(` + + true + + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(importedDir, "Packages.props"), []byte(` + + + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(importedDir, "Proj", "Proj.csproj"), []byte(` + + net8.0 + true + + + + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(importedDir, "Proj", "packages.lock.json"), []byte(`{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"12.0.3"}}}}`), 0644)) + + originalRootProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + originalDirectoryPackages, err := os.ReadFile(filepath.Join(importedDir, "Directory.Packages.props")) + assert.NoError(t, err) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join(importedDir, "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + importedProps, err := os.ReadFile(filepath.Join(importedDir, "Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(importedProps), `Include="Newtonsoft.Json" Version="13.0.1"`) + + directoryPackages, err := os.ReadFile(filepath.Join(importedDir, "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalDirectoryPackages, directoryPackages) + + rootProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalRootProps, rootProps) +} + +func TestNugetUpdateDependencyFollowsMSBuildThisFileDirectoryImport(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + originalDirectoryPackages, err := os.ReadFile(filepath.Join("CpmMacroImport", "Directory.Packages.props")) + assert.NoError(t, err) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("CpmMacroImport", "Proj", "packages.lock.json")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + sharedProps, err := os.ReadFile(filepath.Join("CpmMacroImport", "Shared.props")) + assert.NoError(t, err) + assert.Contains(t, string(sharedProps), `Include="Newtonsoft.Json" Version="13.0.1"`) + + directoryPackages, err := os.ReadFile(filepath.Join("CpmMacroImport", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalDirectoryPackages, directoryPackages) + + lockContent, err := os.ReadFile(filepath.Join("CpmMacroImport", "Proj", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockContent), `"resolved":"13.0.1"`) +} + +func TestNugetUpdateDependencySkipsWhenManagePackageVersionsCentrallyFalse(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + disabledDir := "CpmDisabled" + assert.NoError(t, os.MkdirAll(filepath.Join(disabledDir, "Proj"), 0755)) + propsContent := []byte(` + + false + + + + + +`) + assert.NoError(t, os.WriteFile(filepath.Join(disabledDir, "Directory.Packages.props"), propsContent, 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(disabledDir, "Proj", "Proj.csproj"), []byte(` + + net8.0 + + + + + +`), 0644)) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join(disabledDir, "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, NoInlineVersionFixNotSupported, unsupportedErr.ErrorType) + + unchangedProps, err := os.ReadFile(filepath.Join(disabledDir, "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, propsContent, unchangedProps) +} + +func TestNugetUpdateDependencyRollsBackDirectoryPackagesPropsOnRestoreFailure(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + originalProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + originalLock, err := os.ReadFile(filepath.Join("Cpm", "Proj", "packages.lock.json")) + assert.NoError(t, err) + + toolDir := t.TempDir() + writeFakeDotnetRestore(t, toolDir, 1, `{"corrupted": true}`, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + assert.Contains(t, err.Error(), "dotnet restore failed") + + rolledBackProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalProps, rolledBackProps) + rolledBackLock, err := os.ReadFile(filepath.Join("Cpm", "Proj", "packages.lock.json")) + assert.NoError(t, err) + assert.Equal(t, originalLock, rolledBackLock) +} + +func TestNugetUpdateDependencyTwoProjectsSharingDirectoryPackagesProps(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + otherProjDir := filepath.Join("Cpm", "OtherProj") + assert.NoError(t, os.MkdirAll(otherProjDir, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(otherProjDir, "OtherProj.csproj"), []byte(` + + net8.0 + true + + + + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(otherProjDir, "packages.lock.json"), []byte(`{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"12.0.3"}}}}`), 0644)) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{ + {File: filepath.Join("Cpm", "Proj", "Proj.csproj")}, + {File: filepath.Join(otherProjDir, "OtherProj.csproj")}, + }}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(propsContent), `Include="Newtonsoft.Json" Version="13.0.1"`) + + for _, lockPath := range []string{filepath.Join("Cpm", "Proj", "packages.lock.json"), filepath.Join(otherProjDir, "packages.lock.json")} { + lockContent, readErr := os.ReadFile(lockPath) + assert.NoError(t, readErr) + assert.Contains(t, string(lockContent), `"resolved":"13.0.1"`) + } + + argsLog, err := os.ReadFile(filepath.Join(toolDir, "args.log")) + assert.NoError(t, err) + assert.Equal(t, 2, strings.Count(string(argsLog), "\n")) +} + +func TestNugetUpdateDependencyPackageVersionChildElement(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + assert.NoError(t, os.WriteFile(filepath.Join("Cpm", "Directory.Packages.props"), []byte(` + + true + + + + 12.0.3 + + + +`), 0644)) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(propsContent), `13.0.1`) + assert.NotContains(t, string(propsContent), `12.0.3`) +} + +func TestNugetUpdateDependencyDoesNotFallbackOnMixedInlineAndVersionless(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + csprojPath := filepath.Join("Cpm", "Proj", "Proj.csproj") + assert.NoError(t, os.WriteFile(csprojPath, []byte(` + + net8.0 + true + + + + + + +`), 0644)) + + originalProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: csprojPath}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, MixedInlineAndNonInlineVersionFixNotSupported, unsupportedErr.ErrorType) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalProps, propsContent) +} + +func TestNugetUpdateDependencySkipsAmbiguousLockFileEvidence(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + multiDir := filepath.Join("Cpm", "MultiProj") + assert.NoError(t, os.MkdirAll(multiDir, 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(multiDir, "App.csproj"), []byte(``), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(multiDir, "Lib.csproj"), []byte(``), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(multiDir, "packages.lock.json"), []byte(`{"version":1}`), 0644)) + + originalProps, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join(multiDir, "packages.lock.json")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no NuGet project locations found") + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, originalProps, propsContent) +} + +func TestNugetUpdateDependencyPackageVersionUpdateAttribute(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + assert.NoError(t, os.WriteFile(filepath.Join("Cpm", "Directory.Packages.props"), []byte(` + + true + + + + + +`), 0644)) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(propsContent), `Update="Newtonsoft.Json" Version="13.0.1"`) +} + +func TestNugetUpdateDependencyPackageVersionCaseInsensitive(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + assert.NoError(t, os.WriteFile(filepath.Join("Cpm", "Directory.Packages.props"), []byte(` + + true + + + + + +`), 0644)) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join("Cpm", "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + propsContent, err := os.ReadFile(filepath.Join("Cpm", "Directory.Packages.props")) + assert.NoError(t, err) + assert.Contains(t, string(propsContent), `Include="Newtonsoft.Json" Version="13.0.1"`) +} + +func TestNugetUpdateDependencySkipsWhenCpmDisabledInDirectoryBuildProps(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + disabledDir := "CpmDisabledViaBuild" + assert.NoError(t, os.MkdirAll(filepath.Join(disabledDir, "Proj"), 0755)) + propsContent := []byte(` + + + + +`) + assert.NoError(t, os.WriteFile(filepath.Join(disabledDir, "Directory.Packages.props"), propsContent, 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(disabledDir, "Directory.Build.props"), []byte(` + + false + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(disabledDir, "Proj", "Proj.csproj"), []byte(` + + net8.0 + + + + + +`), 0644)) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join(disabledDir, "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) assert.Equal(t, NoInlineVersionFixNotSupported, unsupportedErr.ErrorType) + + unchangedProps, err := os.ReadFile(filepath.Join(disabledDir, "Directory.Packages.props")) + assert.NoError(t, err) + assert.Equal(t, propsContent, unchangedProps) +} + +func TestNugetUpdateDependencyPackageVersionInDirectoryBuildProps(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + dir := "BuildPropsPackageVersion" + assert.NoError(t, os.MkdirAll(filepath.Join(dir, "Proj"), 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "Directory.Build.props"), []byte(` + + + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "Proj", "Proj.csproj"), []byte(` + + net8.0 + true + + + + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "Proj", "packages.lock.json"), []byte(`{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"12.0.3"}}}}`), 0644)) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join(dir, "Proj", "packages.lock.json")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + buildPropsContent, err := os.ReadFile(filepath.Join(dir, "Directory.Build.props")) + assert.NoError(t, err) + assert.Contains(t, string(buildPropsContent), `Include="Newtonsoft.Json" Version="13.0.1"`) +} + +func TestNugetUpdateDependencyResolvesDirectoryBuildTargets(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + dir := "BuildTargets" + assert.NoError(t, os.MkdirAll(filepath.Join(dir, "Proj"), 0755)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "Directory.Build.targets"), []byte(` + + + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "Proj", "Proj.csproj"), []byte(` + + net8.0 + true + + +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "Proj", "packages.lock.json"), []byte(`{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"12.0.3"}}}}`), 0644)) + + toolDir := t.TempDir() + regeneratedLock := `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}` + writeFakeDotnetRestore(t, toolDir, 0, regeneratedLock, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join(dir, "Proj", "packages.lock.json")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + targetsContent, err := os.ReadFile(filepath.Join(dir, "Directory.Build.targets")) + assert.NoError(t, err) + assert.Contains(t, string(targetsContent), `Include="Newtonsoft.Json" Version="13.0.1"`) +} + +func TestNugetUpdateDependencyClosestDirectoryBuildPropsWins(t *testing.T) { + integration.InitUnitTest(t) + testProjectPath := filepath.Join("..", "..", "..", "tests", "testdata", "projects", "package-managers", "nuget", "remediation-packageupdaters") + currDir, err := os.Getwd() + assert.NoError(t, err) + + tmpDir, err := os.MkdirTemp("", "nuget-test-*") + assert.NoError(t, err) + defer func() { + assert.NoError(t, fileutils.RemoveTempDir(tmpDir)) + }() + assert.NoError(t, biutils.CopyDir(testProjectPath, tmpDir, true, nil)) + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + dir := "BuildPropsNested" + assert.NoError(t, os.MkdirAll(filepath.Join(dir, "src", "Proj"), 0755)) + parentProps := []byte(` + + + + +`) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "Directory.Build.props"), parentProps, 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "src", "Directory.Build.props"), []byte(` +`), 0644)) + assert.NoError(t, os.WriteFile(filepath.Join(dir, "src", "Proj", "Proj.csproj"), []byte(` + + net8.0 + + +`), 0644)) + + fixDetails := &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: filepath.Join(dir, "src", "Proj", "Proj.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, NoInlineVersionFixNotSupported, unsupportedErr.ErrorType) + + unchangedParent, err := os.ReadFile(filepath.Join(dir, "Directory.Build.props")) + assert.NoError(t, err) + assert.Equal(t, parentProps, unchangedParent) } diff --git a/remediation/sca/packageupdaters/types.go b/remediation/sca/packageupdaters/types.go index 1a16a3917..876a3875c 100644 --- a/remediation/sca/packageupdaters/types.go +++ b/remediation/sca/packageupdaters/types.go @@ -24,13 +24,13 @@ type UnsupportedErrorType string const ( IndirectDependencyFixNotSupported UnsupportedErrorType = "IndirectDependencyFixNotSupported" - // NoInlineVersionFixNotSupported covers any PackageReference found with no inline version - - // whether it's actually governed by Central Package Management, supplied via - // Directory.Build.props, overridden elsewhere, or an SDK-implicit reference. The updater can't - // tell these apart from the reference site alone, so it reports them all the same way rather - // than guessing. - NoInlineVersionFixNotSupported UnsupportedErrorType = "NoInlineVersionFixNotSupported" - UnsupportedFixReason UnsupportedErrorType = "UnsupportedFixReason" + // NoInlineVersionFixNotSupported covers a PackageReference with no inline version, no + // VersionOverride, and no resolvable PackageVersion or PackageReference in the nearest + // Directory.Packages.props, Directory.Build.props, Directory.Build.targets, or a file they + // Import - including when Central Package Management is disabled. + NoInlineVersionFixNotSupported UnsupportedErrorType = "NoInlineVersionFixNotSupported" + MixedInlineAndNonInlineVersionFixNotSupported UnsupportedErrorType = "MixedInlineAndNonInlineVersionFixNotSupported" + UnsupportedFixReason UnsupportedErrorType = "UnsupportedFixReason" ) type ErrUnsupportedFix struct { @@ -45,7 +45,9 @@ func (err *ErrUnsupportedFix) Error() string { case IndirectDependencyFixNotSupported: return fmt.Sprintf("skipping fix of vulnerable package '%s' version '%s' - indirect dependency fix is not supported", err.PackageName, err.FixedVersion) case NoInlineVersionFixNotSupported: - return fmt.Sprintf("skipping fix of vulnerable package '%s' version '%s' - no inline version found on the reference (may be centrally managed, supplied via Directory.Build.props, overridden elsewhere, or an SDK-implicit reference) and fixing it is not yet supported", err.PackageName, err.FixedVersion) + return fmt.Sprintf("skipping fix of vulnerable package '%s' version '%s' - could not resolve a version to fix (no inline attribute, VersionOverride, or matching Directory.Packages.props / Directory.Build.props entry found)", err.PackageName, err.FixedVersion) + case MixedInlineAndNonInlineVersionFixNotSupported: + return fmt.Sprintf("skipping fix of vulnerable package '%s' version '%s' - package has both inline and versionless PackageReference entries", err.PackageName, err.FixedVersion) case UnsupportedFixReason: return fmt.Sprintf("skipping fix of vulnerable package '%s' version '%s' - %s", err.PackageName, err.FixedVersion, err.Reason) default: diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Directory.Build.props b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Directory.Build.props new file mode 100644 index 000000000..f7abc52e2 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Directory.Build.props @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/Proj.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/Proj.csproj new file mode 100644 index 000000000..6b6c8424a --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/Proj.csproj @@ -0,0 +1,8 @@ + + + + net8.0 + true + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/packages.lock.json new file mode 100644 index 000000000..11fff8398 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[12.0.3, )", + "resolved": "12.0.3", + "contentHash": "6mgjfnRB4jKMlzHSl+VD+Fk4tsfGyu+CBjBTP1sOnCPQ7z+H5DwCZjxr75tGD1jXTAcgwmttSaTgWJ0t9Uu9jA==" + } + } + } +} diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Directory.Packages.props b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Directory.Packages.props new file mode 100644 index 000000000..b470562c8 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Directory.Packages.props @@ -0,0 +1,8 @@ + + + true + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/Directory.Packages.props b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/Directory.Packages.props new file mode 100644 index 000000000..06d6fabf9 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/Directory.Packages.props @@ -0,0 +1,8 @@ + + + true + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/NestedProj.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/NestedProj.csproj new file mode 100644 index 000000000..c4755f963 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/NestedProj.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/packages.lock.json new file mode 100644 index 000000000..fac02dbf4 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[11.0.2, )", + "resolved": "11.0.2", + "contentHash": "6mgjfnRB4jKMlzHSl+VD+Fk4tsfGyu+CBjBTP1sOnCPQ7z+H5DwCZjxr75tGD1jXTAcgwmttSaTgWJ0t9Uu9jA==" + } + } + } +} diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/Proj.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/Proj.csproj new file mode 100644 index 000000000..b9e956476 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/Proj.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + true + + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/packages.lock.json new file mode 100644 index 000000000..11fff8398 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[12.0.3, )", + "resolved": "12.0.3", + "contentHash": "6mgjfnRB4jKMlzHSl+VD+Fk4tsfGyu+CBjBTP1sOnCPQ7z+H5DwCZjxr75tGD1jXTAcgwmttSaTgWJ0t9Uu9jA==" + } + } + } +} diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/ProjWithOverride.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/ProjWithOverride.csproj new file mode 100644 index 000000000..663d209ab --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/ProjWithOverride.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/packages.lock.json new file mode 100644 index 000000000..4f0c86dc7 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[11.0.1, )", + "resolved": "11.0.1", + "contentHash": "6mgjfnRB4jKMlzHSl+VD+Fk4tsfGyu+CBjBTP1sOnCPQ7z+H5DwCZjxr75tGD1jXTAcgwmttSaTgWJ0t9Uu9jA==" + } + } + } +} diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Directory.Packages.props b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Directory.Packages.props new file mode 100644 index 000000000..00902f368 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Directory.Packages.props @@ -0,0 +1,6 @@ + + + true + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/Proj.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/Proj.csproj new file mode 100644 index 000000000..c4755f963 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/Proj.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/packages.lock.json new file mode 100644 index 000000000..11fff8398 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Newtonsoft.Json": { + "type": "Direct", + "requested": "[12.0.3, )", + "resolved": "12.0.3", + "contentHash": "6mgjfnRB4jKMlzHSl+VD+Fk4tsfGyu+CBjBTP1sOnCPQ7z+H5DwCZjxr75tGD1jXTAcgwmttSaTgWJ0t9Uu9jA==" + } + } + } +} diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Shared.props b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Shared.props new file mode 100644 index 000000000..3881ba67a --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Shared.props @@ -0,0 +1,5 @@ + + + + + diff --git a/utils/results/common.go b/utils/results/common.go index 4febf1e57..f8d2ffcb3 100644 --- a/utils/results/common.go +++ b/utils/results/common.go @@ -1563,12 +1563,11 @@ func CdxEvidencesToPreferredLocation(component cyclonedx.Component) (location *f } } // We need to pick the preferred location from the evidences (we prefer descriptors over lock files) - for _, occurrence := range *component.Evidence.Occurrences { - if techutils.IsTechnologyDescriptor(occurrence.Location) != techutils.NoTech { - return &formats.Location{ - File: occurrence.Location, - } - } + if location := firstDescriptorLocation(*component.Evidence.Occurrences, false); location != nil { + return location + } + if location := firstDescriptorLocation(*component.Evidence.Occurrences, true); location != nil { + return location } // We take the first location as the main location log.Debug(fmt.Sprintf("Multiple locations found for component %s evidence, using the first one as location", component.Name)) @@ -1577,6 +1576,19 @@ func CdxEvidencesToPreferredLocation(component cyclonedx.Component) (location *f } } +func firstDescriptorLocation(occurrences []cyclonedx.EvidenceOccurrence, allowLockFile bool) *formats.Location { + for _, occurrence := range occurrences { + if techutils.IsTechnologyDescriptor(occurrence.Location) == techutils.NoTech { + continue + } + if !allowLockFile && techutils.IsLockFilePackageDescriptor(occurrence.Location) { + continue + } + return &formats.Location{File: occurrence.Location} + } + return nil +} + func getExternalReferencesUrls(component cyclonedx.Component) (externalReferences []string) { if component.ExternalReferences != nil && len(*component.ExternalReferences) > 0 { for _, externalReference := range *component.ExternalReferences { diff --git a/utils/results/common_test.go b/utils/results/common_test.go index ffd47daf5..c85c12dc5 100644 --- a/utils/results/common_test.go +++ b/utils/results/common_test.go @@ -3057,6 +3057,16 @@ func TestCdxEvidencesToPreferredLocation(t *testing.T) { component: cyclonedx.Component{Evidence: &cyclonedx.Evidence{Occurrences: &[]cyclonedx.EvidenceOccurrence{{Location: "requirements.txt"}, {Location: "requirements.lock"}}}}, expected: &formats.Location{File: "requirements.txt"}, }, + { + name: "Component with evidences (nuget prefers csproj over lock)", + component: cyclonedx.Component{Evidence: &cyclonedx.Evidence{Occurrences: &[]cyclonedx.EvidenceOccurrence{{Location: "packages.lock.json"}, {Location: "Proj.csproj"}}}}, + expected: &formats.Location{File: "Proj.csproj"}, + }, + { + name: "Component with nuget lock file only", + component: cyclonedx.Component{Evidence: &cyclonedx.Evidence{Occurrences: &[]cyclonedx.EvidenceOccurrence{{Location: "src/packages.lock.json"}}}}, + expected: &formats.Location{File: "src/packages.lock.json"}, + }, } for _, test := range tests { diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index cd975ae8c..8491b4c0f 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -263,9 +263,13 @@ var technologiesData = map[Technology]TechData{ language: Python, }, Nuget: { - formal: "NuGet", - indicators: []string{".sln", ".slnx", ".csproj", ".fsproj", ".vbproj"}, - packageDescriptors: []string{".sln", ".slnx", ".csproj", ".fsproj", ".vbproj"}, + formal: "NuGet", + indicators: []string{".sln", ".slnx", ".csproj", ".fsproj", ".vbproj"}, + // packages.lock.json is included here too: for a package whose version comes from Central + // Package Management or Directory.Build.props, the .csproj itself never contains the + // version text, so the scanner cites only the lock file as evidence. Without it listed here, + // that evidence is filtered out before it ever reaches the NuGet fix updater. + packageDescriptors: []string{".sln", ".slnx", ".csproj", ".fsproj", ".vbproj", "packages.lock.json"}, // .NET CLI is used for NuGet projects execCommand: "dotnet", packageInstallationCommand: "add", @@ -456,6 +460,10 @@ func IsTechnologyDescriptor(path string) Technology { return NoTech } +func IsLockFilePackageDescriptor(path string) bool { + return strings.EqualFold(filepath.Base(path), "packages.lock.json") +} + func DetectedTechnologiesList() (technologies []string) { wd, err := os.Getwd() if errorutils.CheckError(err) != nil {