diff --git a/remediation/sca/packageupdaters/commonpackageupdater.go b/remediation/sca/packageupdaters/commonpackageupdater.go index 9e081bd0d..28ce0aa20 100644 --- a/remediation/sca/packageupdaters/commonpackageupdater.go +++ b/remediation/sca/packageupdaters/commonpackageupdater.go @@ -47,6 +47,7 @@ var SupportedFixTechnologies = []techutils.Technology{ techutils.Go, techutils.Pnpm, techutils.Docker, + techutils.Nuget, techutils.Yarn, } @@ -64,6 +65,8 @@ func GetCompatiblePackageUpdater(fixDetails *FixDetails) (PackageUpdater, bool) return &PnpmPackageUpdater{}, true case techutils.Docker: return &DockerPackageUpdater{}, true + case techutils.Nuget: + return &NugetPackageUpdater{}, true case techutils.Yarn: return &YarnPackageUpdater{}, true default: diff --git a/remediation/sca/packageupdaters/commonpackageupdater_test.go b/remediation/sca/packageupdaters/commonpackageupdater_test.go index 38f768b1c..fb1c6b5e1 100644 --- a/remediation/sca/packageupdaters/commonpackageupdater_test.go +++ b/remediation/sca/packageupdaters/commonpackageupdater_test.go @@ -168,6 +168,22 @@ func TestUpdateDependency(t *testing.T) { descriptorsToCheck: []string{"package.json"}, }, }, + + // Nuget test cases - exercises the real 'dotnet' CLI (unlike the fake-dotnet unit tests in + // nugetpackageupdater_test.go), to confirm '--force-evaluate --no-dependencies' are flags + // a real restore actually accepts and acts on. + { + { + fixDetails: createFixDetails(techutils.Nuget, "Newtonsoft.Json", "", "13.0.1", false, ""), + fixSupported: false, + }, + { + fixDetails: createFixDetails(techutils.Nuget, "Newtonsoft.Json", "", "13.0.1", true, "Remediation.csproj"), + fixSupported: true, + descriptorsToCheck: []string{"Remediation.csproj"}, + lockFileToVerifyItsChange: "packages.lock.json", + }, + }, } for _, testBatch := range testCases { @@ -266,8 +282,13 @@ func assertFixVersionInPackageDescriptor(t *testing.T, test dependencyFixTest, p assert.NoError(t, err) assert.Contains(t, string(file), test.fixDetails.SuggestedFixedVersion) - // Verify that case-sensitive packages in python are lowered - assert.Contains(t, string(file), strings.ToLower(test.fixDetails.ImpactedDependencyName)) + expectedName := test.fixDetails.ImpactedDependencyName + switch test.fixDetails.Technology { + case techutils.Pip, techutils.Poetry, techutils.Pipenv: + // Python package names are normalized to lowercase on fix. + expectedName = strings.ToLower(expectedName) + } + assert.Contains(t, string(file), expectedName) } } @@ -982,9 +1003,9 @@ func TestGetCompatiblePackageUpdater(t *testing.T) { {techutils.Pip, true, &PythonPackageUpdater{}}, {techutils.Poetry, true, &PythonPackageUpdater{}}, {techutils.Pipenv, true, &PythonPackageUpdater{}}, + {techutils.Nuget, true, &NugetPackageUpdater{}}, {techutils.Yarn, true, &YarnPackageUpdater{}}, {techutils.Gradle, false, nil}, - {techutils.Nuget, false, nil}, {techutils.Conan, false, nil}, } for _, tt := range tests { diff --git a/remediation/sca/packageupdaters/nugetpackageupdater.go b/remediation/sca/packageupdaters/nugetpackageupdater.go new file mode 100644 index 000000000..a1a170b5d --- /dev/null +++ b/remediation/sca/packageupdaters/nugetpackageupdater.go @@ -0,0 +1,260 @@ +package packageupdaters + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/jfrog/jfrog-client-go/utils/log" +) + +// nugetProjectFileSuffixes are matched case-insensitively, since MSBuild project file suffixes +// aren't guaranteed to be written in any particular casing. +var nugetProjectFileSuffixes = []string{".csproj", ".fsproj", ".vbproj"} + +const ( + // (?i) accounts for MSBuild element names being case-insensitive (e.g. is + // just as valid as ), even though this casing is rare in practice. + nugetPackageReferenceElementPattern = `(?is)]*/>|]*[^/]>.*?` + nugetKeyAttrPattern = `(?i)\b(?:Include|Update)\s*=\s*["']%s["']` + // nugetVersionAttrPattern matches whatever is already inside Version="...", including an + // MSBuild property reference like "$(FooVersion)" - such a reference gets overwritten with the + // 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)()[^<]*()` + + nugetLockFileName = "packages.lock.json" + nugetObjDirName = "obj" + + nugetRestoreForceEvaluateFlag = "--force-evaluate" + // --no-dependencies keeps a fix scoped to the touched project's own lock file, instead of also + // restoring (and diffing) every project it references via ProjectReference - a deliberate + // divergence from what Renovate/Dependabot themselves pass. + // + // Known limitation: if the bumped package's transitive dependencies are only pulled in through + // a referenced project (not the touched project itself), that referenced project's own + // packages.lock.json can end up stale relative to the new resolution, since --no-dependencies + // prevents restore from touching it at all. + nugetRestoreNoDependenciesFlag = "--no-dependencies" +) + +// NugetRestoreEnvVars suppresses first-run banner noise and telemetry prompts observed when +// invoking a freshly-installed dotnet CLI, on top of the inherited environment. +var NugetRestoreEnvVars = map[string]string{ + "DOTNET_NOLOGO": "1", + "DOTNET_CLI_TELEMETRY_OPTOUT": "1", + "DOTNET_SKIP_FIRST_TIME_EXPERIENCE": "1", +} + +type NugetPackageUpdater struct { + CommonPackageUpdater +} + +func (n *NugetPackageUpdater) UpdateDependency(fixDetails *FixDetails) error { + if !fixDetails.IsDirectDependency { + return &ErrUnsupportedFix{ + PackageName: fixDetails.ImpactedDependencyName, + FixedVersion: fixDetails.SuggestedFixedVersion, + ErrorType: IndirectDependencyFixNotSupported, + } + } + + projectFilePaths := collectProjectFilePaths(fixDetails) + if len(projectFilePaths) == 0 { + return fmt.Errorf("no NuGet project locations found for %s - Components array is empty or missing Location data", fixDetails.ImpactedDependencyName) + } + log.Verbose(fmt.Sprintf("Found vulnerability %s occurrences for component %s in %s", fixDetails.IssueId, fixDetails.ImpactedDependencyVersion, strings.Join(projectFilePaths, ", "))) + + originalWd, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current working directory: %w", err) + } + + var fixErrors error + var failingDescriptors []string + for _, projectFilePath := range projectFilePaths { + if fixErr := n.fixVulnerabilityAndRestore(projectFilePath, fixDetails.ImpactedDependencyName, fixDetails.SuggestedFixedVersion, originalWd); fixErr != nil { + log.Warn(fixErr.Error()) + fixErrors = errors.Join(fixErrors, fmt.Errorf("failed to fix '%s' in descriptor '%s': %w", fixDetails.ImpactedDependencyName, projectFilePath, fixErr)) + failingDescriptors = append(failingDescriptors, projectFilePath) + } else { + log.Debug("Updated successfully " + projectFilePath) + } + } + + if fixErrors != nil { + return fmt.Errorf("encountered errors while fixing '%s' vulnerability in descriptors [%s]: %w", fixDetails.ImpactedDependencyName, strings.Join(failingDescriptors, ", "), fixErrors) + } + return nil +} + +func collectProjectFilePaths(fixDetails *FixDetails) []string { + var paths []string + for _, path := range GetVulnerabilityLocations(fixDetails, []string{}, []string{}) { + if hasNugetProjectFileSuffix(path) { + paths = append(paths, path) + } + } + return paths +} + +func hasNugetProjectFileSuffix(path string) bool { + lowerPath := strings.ToLower(path) + for _, suffix := range nugetProjectFileSuffixes { + if strings.HasSuffix(lowerPath, suffix) { + return true + } + } + return false +} + +func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packageName, fixedVersion, originalWd string) error { + //#nosec G304 -- projectFilePath from descriptor discovery in the scanned repository. + originalProjectFile, err := os.ReadFile(projectFilePath) + if err != nil { + 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) + } + + //#nosec G703 G306 -- projectFilePath from scan workflow; 0644 for VCS-tracked sources. + if err = os.WriteFile(projectFilePath, updatedProjectFile, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", projectFilePath, err) + } + + 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)) + } + + absLockFilePath := lockFilePath + if !filepath.IsAbs(absLockFilePath) { + absLockFilePath = filepath.Join(originalWd, absLockFilePath) + } + lockFileTracked, checkErr := IsFileTrackedByGit(absLockFilePath, originalWd) + if checkErr != nil { + log.Debug(fmt.Sprintf("Failed to check if lock file is tracked in git: %s. Proceeding with lock file regeneration.", checkErr.Error())) + lockFileTracked = true + } + if !lockFileTracked { + log.Debug(fmt.Sprintf("Lock file '%s' is not tracked in git, skipping lock file regeneration", lockFilePath)) + return nil + } + + 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 nil +} + +func (n *NugetPackageUpdater) runDotnetRestore(projectFilePath string) error { + objDir := filepath.Join(filepath.Dir(projectFilePath), nugetObjDirName) + objDirExisted := dirExists(objDir) + defer func() { + if objDirExisted { + return + } + if cleanupErr := os.RemoveAll(objDir); cleanupErr != nil { + log.Warn(fmt.Sprintf("Failed to remove restore-generated '%s': %s", objDir, cleanupErr.Error())) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), nodePackageManagerInstallTimeout) + defer cancel() + + //#nosec G204 -- projectFilePath from descriptor discovery; runs only after user approval. + cmd := exec.CommandContext(ctx, "dotnet", "restore", projectFilePath, nugetRestoreForceEvaluateFlag, nugetRestoreNoDependenciesFlag) + cmd.Env = n.BuildEnvWithOverrides(NugetRestoreEnvVars) + log.Debug(fmt.Sprintf("Running 'dotnet restore %s %s %s'", projectFilePath, nugetRestoreForceEvaluateFlag, nugetRestoreNoDependenciesFlag)) + + output, err := cmd.CombinedOutput() + if len(output) > 0 { + log.Debug(fmt.Sprintf("dotnet restore output:\n%s", string(output))) + } + if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("dotnet restore timed out after %v", nodePackageManagerInstallTimeout) + } + if err != nil { + return fmt.Errorf("dotnet restore failed: %s\n%s", err.Error(), output) + } + return nil +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func rollbackProjectFile(projectFilePath string, originalProjectFile []byte, origErr error) error { + //#nosec G703 G306 -- projectFilePath from scan workflow; 0644 for VCS-tracked sources. + if rollbackErr := os.WriteFile(projectFilePath, originalProjectFile, 0644); rollbackErr != nil { + return fmt.Errorf("failed to rollback '%s': %w (original error: %v)", projectFilePath, rollbackErr, origErr) + } + return origErr +} + +func rollbackProjectFileAndLock(projectFilePath string, originalProjectFile []byte, lockFilePath string, originalLockFile []byte, origErr error) error { + //#nosec G703 G306 -- projectFilePath from scan workflow; 0644 for VCS-tracked sources. + if rollbackErr := os.WriteFile(projectFilePath, originalProjectFile, 0644); rollbackErr != nil { + return fmt.Errorf("failed to rollback '%s': %w (original error: %v)", projectFilePath, rollbackErr, origErr) + } + //#nosec G703 G306 -- lockFilePath derived from projectFilePath, from the same scan workflow. + if rollbackErr := os.WriteFile(lockFilePath, originalLockFile, 0644); rollbackErr != nil { + return fmt.Errorf("failed to rollback '%s': %w (original error: %v)", lockFilePath, rollbackErr, origErr) + } + return origErr +} + +func updatePackageReferenceVersion(content []byte, packageName, fixedVersion string) ([]byte, error) { + element := regexp.MustCompile(nugetPackageReferenceElementPattern) + keyAttr := regexp.MustCompile(fmt.Sprintf(nugetKeyAttrPattern, regexp.QuoteMeta(packageName))) + versionAttr := regexp.MustCompile(nugetVersionAttrPattern) + versionElement := regexp.MustCompile(nugetVersionElementPattern) + + var fixedAny, foundWithoutVersion 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: + foundWithoutVersion = true + return match + } + }) + + if foundWithoutVersion { + return nil, &ErrUnsupportedFix{ + PackageName: packageName, + FixedVersion: fixedVersion, + ErrorType: NoInlineVersionFixNotSupported, + } + } + if fixedAny { + return updatedContent, nil + } + return nil, fmt.Errorf("dependency %s not found", packageName) +} diff --git a/remediation/sca/packageupdaters/nugetpackageupdater_test.go b/remediation/sca/packageupdaters/nugetpackageupdater_test.go new file mode 100644 index 000000000..cff5c3f83 --- /dev/null +++ b/remediation/sca/packageupdaters/nugetpackageupdater_test.go @@ -0,0 +1,924 @@ +package packageupdaters + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + biutils "github.com/jfrog/build-info-go/utils" + "github.com/jfrog/jfrog-client-go/utils/io/fileutils" + "github.com/stretchr/testify/assert" + + "github.com/jfrog/jfrog-cli-security/tests/utils/integration" + "github.com/jfrog/jfrog-cli-security/utils/formats" + "github.com/jfrog/jfrog-cli-security/utils/techutils" +) + +func writeFakeDotnetRestore(t *testing.T, dir string, exitCode int, lockFileContent string, createObjDir bool) { + lockContentPath := filepath.Join(dir, "lockfile-content.json") + assert.NoError(t, os.WriteFile(lockContentPath, []byte(lockFileContent), 0o644)) + argsLogPath := filepath.Join(dir, "args.log") + + if runtime.GOOS == "windows" { + mkObjLine := "" + if createObjDir { + mkObjLine = "if not exist \"%projdir%obj\" mkdir \"%projdir%obj\"\r\n" + } + script := "@echo off\r\n" + + "echo %*>>\"" + argsLogPath + "\"\r\n" + + "for %%F in (\"%2\") do set projdir=%%~dpF\r\n" + + "copy /Y \"" + lockContentPath + "\" \"%projdir%packages.lock.json\">nul\r\n" + + mkObjLine + + "exit /b " + strconv.Itoa(exitCode) + "\r\n" + assert.NoError(t, os.WriteFile(filepath.Join(dir, "dotnet.cmd"), []byte(script), 0o755)) + return + } + + mkObjLine := "" + if createObjDir { + mkObjLine = "mkdir -p \"$projdir/obj\"\n" + } + script := "#!/bin/sh\n" + + "echo \"$@\" >> \"" + argsLogPath + "\"\n" + + "projdir=$(dirname \"$2\")\n" + + "cp \"" + lockContentPath + "\" \"$projdir/packages.lock.json\"\n" + + mkObjLine + + "exit " + strconv.Itoa(exitCode) + "\n" + assert.NoError(t, os.WriteFile(filepath.Join(dir, "dotnet"), []byte(script), 0o755)) +} + +func writeFakeDotnetRestoreFailingForPath(t *testing.T, dir string, pathMarker string, lockFileContent string) { + lockContentPath := filepath.Join(dir, "lockfile-content.json") + assert.NoError(t, os.WriteFile(lockContentPath, []byte(lockFileContent), 0o644)) + + if runtime.GOOS == "windows" { + script := "@echo off\r\n" + + "echo %2 | findstr /C:\"" + pathMarker + "\" >nul\r\n" + + "if %errorlevel%==0 exit /b 1\r\n" + + "for %%F in (\"%2\") do set projdir=%%~dpF\r\n" + + "copy /Y \"" + lockContentPath + "\" \"%projdir%packages.lock.json\">nul\r\n" + + "exit /b 0\r\n" + assert.NoError(t, os.WriteFile(filepath.Join(dir, "dotnet.cmd"), []byte(script), 0o755)) + return + } + + script := "#!/bin/sh\n" + + "case \"$2\" in\n" + + " *" + pathMarker + "*) exit 1 ;;\n" + + "esac\n" + + "projdir=$(dirname \"$2\")\n" + + "cp \"" + lockContentPath + "\" \"$projdir/packages.lock.json\"\n" + + "exit 0\n" + assert.NoError(t, os.WriteFile(filepath.Join(dir, "dotnet"), []byte(script), 0o755)) +} + +func TestNugetUpdateDependency(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) + + updateAttributeCsproj := ` + + net8.0 + + + + +` + + childElementVersionCsproj := ` + + net8.0 + + + + 12.0.3 + + +` + + lowercaseElementCsproj := ` + + net8.0 + + + + +` + + testCases := []struct { + name string + customCsproj string + fixDetails *FixDetails + expectedContains []string + expectedNotContain []string + }{ + { + name: "IncludeThenVersion", + fixDetails: &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + expectedContains: []string{`Include="Newtonsoft.Json" Version="13.0.1"`}, + expectedNotContain: []string{`Version="12.0.3"`}, + }, + { + name: "VersionThenInclude", + fixDetails: &FixDetails{ + SuggestedFixedVersion: "2.12.0", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Serilog", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + expectedContains: []string{`Version="2.12.0" Include="Serilog"`}, + expectedNotContain: []string{`Version="2.10.0"`}, + }, + { + name: "UpdateAttribute", + customCsproj: updateAttributeCsproj, + fixDetails: &FixDetails{ + SuggestedFixedVersion: "17.9.0", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Microsoft.NET.Test.Sdk", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + expectedContains: []string{`Update="Microsoft.NET.Test.Sdk" Version="17.9.0"`}, + expectedNotContain: []string{`Version="17.8.0"`}, + }, + { + name: "ChildElementVersion", + customCsproj: childElementVersionCsproj, + fixDetails: &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + expectedContains: []string{`13.0.1`}, + expectedNotContain: []string{`12.0.3`}, + }, + { + name: "LowercaseElementName", + customCsproj: lowercaseElementCsproj, + fixDetails: &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + expectedContains: []string{`Include="Newtonsoft.Json" Version="13.0.1"`}, + expectedNotContain: []string{`Version="12.0.3"`}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + 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)) + if tc.customCsproj != "" { + assert.NoError(t, os.WriteFile(filepath.Join(tmpDir, "Project.csproj"), []byte(tc.customCsproj), 0644)) + } + assert.NoError(t, os.Chdir(tmpDir)) + defer func() { + assert.NoError(t, os.Chdir(currDir)) + }() + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(tc.fixDetails) + assert.NoError(t, err) + + modifiedCsproj, err := os.ReadFile("Project.csproj") + assert.NoError(t, err) + content := string(modifiedCsproj) + for _, s := range tc.expectedContains { + assert.Contains(t, content, s) + } + for _, s := range tc.expectedNotContain { + assert.NotContains(t, content, s) + } + }) + } +} + +func TestNugetUpdateDependencyPartialFailureKeepsSuccessfulWrites(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: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{ + {File: "Project.csproj"}, + {File: filepath.Join("CpmSibling", "CpmSibling.csproj")}, + }}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + // Matches Maven/npm: a sibling failure is still surfaced as an error, but it doesn't roll back + // whatever other descriptors were already fixed successfully. + assert.Error(t, err) + + fixedProject, err := os.ReadFile("Project.csproj") + assert.NoError(t, err) + assert.Contains(t, string(fixedProject), `Include="Newtonsoft.Json" Version="13.0.1"`) + + cpmSibling, err := os.ReadFile(filepath.Join("CpmSibling", "CpmSibling.csproj")) + assert.NoError(t, err) + assert.Contains(t, string(cpmSibling), `Include="Newtonsoft.Json" />`) +} + +func TestNugetUpdateDependencyMultipleIndependentProjects(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: "13.0.1", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{ + {File: "Project.csproj"}, + {File: filepath.Join("IndependentSibling", "IndependentSibling.csproj")}, + }}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + fixedProject, err := os.ReadFile("Project.csproj") + assert.NoError(t, err) + projectContent := string(fixedProject) + assert.Contains(t, projectContent, `Include="Newtonsoft.Json" Version="13.0.1"`) + assert.NotContains(t, projectContent, `Version="12.0.3"`) + assert.Contains(t, projectContent, `Version="2.10.0" Include="Serilog"`) + + fixedSibling, err := os.ReadFile(filepath.Join("IndependentSibling", "IndependentSibling.csproj")) + assert.NoError(t, err) + siblingContent := string(fixedSibling) + assert.Contains(t, siblingContent, `Include="Newtonsoft.Json" Version="13.0.1"`) + assert.NotContains(t, siblingContent, `Version="11.0.2"`) + assert.Contains(t, siblingContent, `Include="NUnit" Version="3.13.3"`) +} + +func TestNugetUpdateDependencyMixedLockFilePresence(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: "Project.csproj"}, + {File: filepath.Join("WithLockFile", "WithLockFile.csproj")}, + }}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + fixedProject, err := os.ReadFile("Project.csproj") + assert.NoError(t, err) + assert.Contains(t, string(fixedProject), `Include="Newtonsoft.Json" Version="13.0.1"`) + _, statErr := os.Stat("packages.lock.json") + assert.True(t, os.IsNotExist(statErr), "no lock file should appear next to a project that never had one") + + fixedWithLock, err := os.ReadFile(filepath.Join("WithLockFile", "WithLockFile.csproj")) + assert.NoError(t, err) + assert.Contains(t, string(fixedWithLock), `Include="Newtonsoft.Json" Version="13.0.1"`) + lockFile, err := os.ReadFile(filepath.Join("WithLockFile", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockFile), `"resolved":"13.0.1"`) + + argsLog, err := os.ReadFile(filepath.Join(toolDir, "args.log")) + assert.NoError(t, err) + assert.Equal(t, 1, strings.Count(string(argsLog), "\n"), "restore should only run once, for the project that actually has a lock file") +} + +func TestNugetUpdateDependencyRegeneratesLockFile(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("WithLockFile", "WithLockFile.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + fixedCsproj, err := os.ReadFile(filepath.Join("WithLockFile", "WithLockFile.csproj")) + assert.NoError(t, err) + assert.Contains(t, string(fixedCsproj), `Include="Newtonsoft.Json" Version="13.0.1"`) + + lockFile, err := os.ReadFile(filepath.Join("WithLockFile", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(lockFile), `"resolved":"13.0.1"`) +} + +// TestNugetUpdateDependencySkipsRestoreForUntrackedLockFile uses a real git repository (rather than +// a bare temp dir, as every other test in this file does) so that IsFileTrackedByGit takes its +// genuine "not tracked" path instead of failing open because the directory isn't a git repo at all. +func TestNugetUpdateDependencySkipsRestoreForUntrackedLockFile(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)) + }() + + repo, err := git.PlainInit(tmpDir, false) + assert.NoError(t, err) + worktree, err := repo.Worktree() + assert.NoError(t, err) + _, err = worktree.Add(filepath.Join("WithLockFile", "WithLockFile.csproj")) + assert.NoError(t, err) + // packages.lock.json is deliberately left untracked (not added/committed). + signature := &object.Signature{Name: "test", Email: "test@example.com", When: time.Now()} + _, err = worktree.Commit("track project file only", &git.CommitOptions{Author: signature}) + assert.NoError(t, err) + + toolDir := t.TempDir() + writeFakeDotnetRestore(t, toolDir, 0, `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}`, false) + t.Setenv("PATH", toolDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + originalLockFile, err := os.ReadFile(filepath.Join("WithLockFile", "packages.lock.json")) + 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("WithLockFile", "WithLockFile.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + fixedCsproj, err := os.ReadFile(filepath.Join("WithLockFile", "WithLockFile.csproj")) + assert.NoError(t, err) + assert.Contains(t, string(fixedCsproj), `Include="Newtonsoft.Json" Version="13.0.1"`, "the reference itself is still updated regardless of lock file tracking") + + lockFileAfter, err := os.ReadFile(filepath.Join("WithLockFile", "packages.lock.json")) + assert.NoError(t, err) + assert.Equal(t, originalLockFile, lockFileAfter, "untracked lock file must be left untouched, not regenerated") + + _, statErr := os.Stat(filepath.Join(toolDir, "args.log")) + assert.True(t, os.IsNotExist(statErr), "dotnet restore must not run at all for an untracked lock file") +} + +func TestNugetUpdateDependencyMultipleProjectsEachRegenerateOwnLockFile(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("WithLockFile", "WithLockFile.csproj")}, + {File: filepath.Join("FailProjectLockFile", "FailProjectLockFile.csproj")}, + }}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + for _, dir := range []string{"WithLockFile", "FailProjectLockFile"} { + lockFile, readErr := os.ReadFile(filepath.Join(dir, "packages.lock.json")) + assert.NoError(t, readErr) + assert.Contains(t, string(lockFile), `"resolved":"13.0.1"`, "lock file in %s should have been regenerated", dir) + } +} + +func TestNugetUpdateDependencyRestoreFailureIsolatedPerProject(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)) + }() + + originalFailProjectCsproj, err := os.ReadFile(filepath.Join("FailProjectLockFile", "FailProjectLockFile.csproj")) + assert.NoError(t, err) + originalFailProjectLock, err := os.ReadFile(filepath.Join("FailProjectLockFile", "packages.lock.json")) + assert.NoError(t, err) + + toolDir := t.TempDir() + writeFakeDotnetRestoreFailingForPath(t, toolDir, "FailProjectLockFile", `{"version":1,"dependencies":{"net8.0":{"Newtonsoft.Json":{"type":"Direct","resolved":"13.0.1"}}}}`) + 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("WithLockFile", "WithLockFile.csproj")}, + {File: filepath.Join("FailProjectLockFile", "FailProjectLockFile.csproj")}, + }}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + // The failing project is still reported as an error, but its rollback stays isolated to itself - + // it must not affect the sibling project that was fixed successfully. + assert.Error(t, err) + + fixedCsproj, err := os.ReadFile(filepath.Join("WithLockFile", "WithLockFile.csproj")) + assert.NoError(t, err) + assert.Contains(t, string(fixedCsproj), `Include="Newtonsoft.Json" Version="13.0.1"`) + fixedLock, err := os.ReadFile(filepath.Join("WithLockFile", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(fixedLock), `"resolved":"13.0.1"`) + + rolledBackCsproj, err := os.ReadFile(filepath.Join("FailProjectLockFile", "FailProjectLockFile.csproj")) + assert.NoError(t, err) + assert.Equal(t, originalFailProjectCsproj, rolledBackCsproj) + rolledBackLock, err := os.ReadFile(filepath.Join("FailProjectLockFile", "packages.lock.json")) + assert.NoError(t, err) + assert.Equal(t, originalFailProjectLock, rolledBackLock) +} + +func TestNugetUpdateDependencyDoesNotTouchReferencedProject(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)) + }() + + originalReferencedCsproj, err := os.ReadFile(filepath.Join("ReferencedProject", "ReferencedProject.csproj")) + assert.NoError(t, err) + originalReferencedLock, err := os.ReadFile(filepath.Join("ReferencedProject", "packages.lock.json")) + 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("WithProjectReference", "WithProjectReference.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + fixedCsproj, err := os.ReadFile(filepath.Join("WithProjectReference", "WithProjectReference.csproj")) + assert.NoError(t, err) + fixedContent := string(fixedCsproj) + assert.Contains(t, fixedContent, `Include="Newtonsoft.Json" Version="13.0.1"`) + assert.Contains(t, fixedContent, ``) + + fixedLock, err := os.ReadFile(filepath.Join("WithProjectReference", "packages.lock.json")) + assert.NoError(t, err) + assert.Contains(t, string(fixedLock), `"resolved":"13.0.1"`) + + referencedCsproj, err := os.ReadFile(filepath.Join("ReferencedProject", "ReferencedProject.csproj")) + assert.NoError(t, err) + assert.Equal(t, originalReferencedCsproj, referencedCsproj) + referencedLock, err := os.ReadFile(filepath.Join("ReferencedProject", "packages.lock.json")) + assert.NoError(t, err) + assert.Equal(t, originalReferencedLock, referencedLock) +} + +func TestNugetUpdateDependencyRollsBackOnRestoreFailure(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)) + }() + + originalLockFile, err := os.ReadFile(filepath.Join("WithLockFile", "packages.lock.json")) + assert.NoError(t, err) + originalCsproj, err := os.ReadFile(filepath.Join("WithLockFile", "WithLockFile.csproj")) + 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("WithLockFile", "WithLockFile.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.Error(t, err) + assert.Contains(t, err.Error(), "dotnet restore failed") + + rolledBackCsproj, err := os.ReadFile(filepath.Join("WithLockFile", "WithLockFile.csproj")) + assert.NoError(t, err) + assert.Equal(t, originalCsproj, rolledBackCsproj) + + rolledBackLockFile, err := os.ReadFile(filepath.Join("WithLockFile", "packages.lock.json")) + assert.NoError(t, err) + assert.Equal(t, originalLockFile, rolledBackLockFile) +} + +func TestNugetUpdateDependencyErrors(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) + + testCases := []struct { + name string + fixDetails *FixDetails + useTestData bool + assertErr func(t *testing.T, err error) + }{ + { + name: "DependencyNotFound", + fixDetails: &FixDetails{ + SuggestedFixedVersion: "1.0.0", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "NonExistent.Package", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + useTestData: true, + assertErr: func(t *testing.T, err error) { + assert.Error(t, err) + assert.Contains(t, err.Error(), "NonExistent.Package") + }, + }, + { + name: "NoInlineVersionNotSupported", + fixDetails: &FixDetails{ + SuggestedFixedVersion: "1.1.118", + IsDirectDependency: true, + Technology: techutils.Nuget, + ImpactedDependencyName: "StyleCop.Analyzers", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + useTestData: true, + assertErr: func(t *testing.T, err error) { + assert.Error(t, err) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, NoInlineVersionFixNotSupported, unsupportedErr.ErrorType) + }, + }, + { + name: "IndirectDependencyNotSupported", + fixDetails: &FixDetails{ + SuggestedFixedVersion: "13.0.1", + IsDirectDependency: false, + Technology: techutils.Nuget, + ImpactedDependencyName: "Newtonsoft.Json", + Components: []formats.ComponentRow{{Evidences: []formats.Location{{File: "Project.csproj"}}}}, + }, + useTestData: false, + assertErr: func(t *testing.T, err error) { + assert.Error(t, err) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, IndirectDependencyFixNotSupported, unsupportedErr.ErrorType) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.useTestData { + 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)) + }() + } + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(tc.fixDetails) + tc.assertErr(t, err) + }) + } +} + +func TestHasNugetProjectFileSuffix(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"Project.csproj", true}, + {"Project.CSProj", true}, + {"Project.CSPROJ", true}, + {"Project.fsproj", true}, + {"Project.FSPROJ", true}, + {"Project.vbproj", true}, + {"Project.VBPROJ", true}, + {filepath.Join("src", "Project.csproj"), true}, + {"Directory.Packages.props", false}, + {"packages.config", false}, + {"Project.sln", false}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + assert.Equal(t, tt.want, hasNugetProjectFileSuffix(tt.path)) + }) + } +} + +func TestNugetUpdateDependencyRestoreScopedFlags(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() + writeFakeDotnetRestore(t, toolDir, 0, `{}`, 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("WithLockFile", "WithLockFile.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + argsLog, err := os.ReadFile(filepath.Join(toolDir, "args.log")) + assert.NoError(t, err) + assert.Contains(t, string(argsLog), "--force-evaluate") + assert.Contains(t, string(argsLog), "--no-dependencies") +} + +func TestNugetUpdateDependencyCleansUpGeneratedObjDir(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)) + }() + + objDir := filepath.Join("WithLockFile", "obj") + _, statErr := os.Stat(objDir) + assert.True(t, os.IsNotExist(statErr), "obj/ should not exist before the fix") + + toolDir := t.TempDir() + writeFakeDotnetRestore(t, toolDir, 0, `{}`, true) + 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("WithLockFile", "WithLockFile.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + _, statErr = os.Stat(objDir) + assert.True(t, os.IsNotExist(statErr), "obj/ created by restore should be cleaned up afterward") +} + +func TestNugetUpdateDependencyPreservesPreexistingObjDir(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)) + }() + + objDir := filepath.Join("WithLockFile", "obj") + assert.NoError(t, os.MkdirAll(objDir, 0755)) + sentinelPath := filepath.Join(objDir, "sentinel.txt") + assert.NoError(t, os.WriteFile(sentinelPath, []byte("keep-me"), 0644)) + + toolDir := t.TempDir() + writeFakeDotnetRestore(t, toolDir, 0, `{}`, true) + 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("WithLockFile", "WithLockFile.csproj")}}}}, + } + + updater := &NugetPackageUpdater{} + err = updater.UpdateDependency(fixDetails) + assert.NoError(t, err) + + sentinel, err := os.ReadFile(sentinelPath) + assert.NoError(t, err) + assert.Equal(t, "keep-me", string(sentinel)) +} + +func TestUpdatePackageReferenceVersionRejectsMixedInlineAndNonInline(t *testing.T) { + content := []byte(` + + + + +`) + + updated, err := updatePackageReferenceVersion(content, "Newtonsoft.Json", "13.0.1") + assert.Nil(t, updated) + var unsupportedErr *ErrUnsupportedFix + assert.True(t, errors.As(err, &unsupportedErr)) + assert.Equal(t, NoInlineVersionFixNotSupported, unsupportedErr.ErrorType) +} diff --git a/remediation/sca/packageupdaters/types.go b/remediation/sca/packageupdaters/types.go index 74e85aae6..1a16a3917 100644 --- a/remediation/sca/packageupdaters/types.go +++ b/remediation/sca/packageupdaters/types.go @@ -24,7 +24,13 @@ type UnsupportedErrorType string const ( IndirectDependencyFixNotSupported UnsupportedErrorType = "IndirectDependencyFixNotSupported" - UnsupportedFixReason UnsupportedErrorType = "UnsupportedFixReason" + // 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" ) type ErrUnsupportedFix struct { @@ -38,6 +44,8 @@ func (err *ErrUnsupportedFix) Error() string { switch err.ErrorType { 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) 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/indirect-project/Placeholder.csproj b/tests/testdata/projects/package-managers/nuget/indirect-project/Placeholder.csproj new file mode 100644 index 000000000..1573afd7f --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/indirect-project/Placeholder.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmSibling/CpmSibling.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmSibling/CpmSibling.csproj new file mode 100644 index 000000000..8ec0b84e2 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmSibling/CpmSibling.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/FailProjectLockFile/FailProjectLockFile.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/FailProjectLockFile/FailProjectLockFile.csproj new file mode 100644 index 000000000..d6059befe --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/FailProjectLockFile/FailProjectLockFile.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/FailProjectLockFile/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/FailProjectLockFile/packages.lock.json new file mode 100644 index 000000000..11fff8398 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/FailProjectLockFile/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/IndependentSibling/IndependentSibling.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/IndependentSibling/IndependentSibling.csproj new file mode 100644 index 000000000..d756f0198 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/IndependentSibling/IndependentSibling.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Project.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Project.csproj new file mode 100644 index 000000000..64d587786 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Project.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + + + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/ReferencedProject/ReferencedProject.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/ReferencedProject/ReferencedProject.csproj new file mode 100644 index 000000000..d372a7f28 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/ReferencedProject/ReferencedProject.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/ReferencedProject/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/ReferencedProject/packages.lock.json new file mode 100644 index 000000000..740d68ca6 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/ReferencedProject/packages.lock.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Serilog": { + "type": "Direct", + "requested": "[2.10.0, )", + "resolved": "2.10.0", + "contentHash": "N0654CBHz7audO23dz8dLEQIVWZi4uY29VkQ7T0Wo9CtHvbpfoAcGRAO/nsr1oOMz2Sza2SLnrhTNMH4X8IX6w==" + } + } + } +} diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithLockFile/WithLockFile.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithLockFile/WithLockFile.csproj new file mode 100644 index 000000000..d6059befe --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithLockFile/WithLockFile.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithLockFile/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithLockFile/packages.lock.json new file mode 100644 index 000000000..11fff8398 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithLockFile/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/WithProjectReference/WithProjectReference.csproj b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithProjectReference/WithProjectReference.csproj new file mode 100644 index 000000000..dc5a9cb60 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithProjectReference/WithProjectReference.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + true + + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithProjectReference/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithProjectReference/packages.lock.json new file mode 100644 index 000000000..11fff8398 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/WithProjectReference/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/Remediation.csproj b/tests/testdata/projects/package-managers/nuget/remediation/Remediation.csproj new file mode 100644 index 000000000..d6059befe --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation/Remediation.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + true + + + + + + + diff --git a/tests/testdata/projects/package-managers/nuget/remediation/packages.lock.json b/tests/testdata/projects/package-managers/nuget/remediation/packages.lock.json new file mode 100644 index 000000000..4f7e12682 --- /dev/null +++ b/tests/testdata/projects/package-managers/nuget/remediation/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+oUc1IebOZabkbyWj2RiTgWwYPPuaK1H97G1sHqGwPlS5npiF5Q0OrxN1wni2n5QWg==" + } + } + } +} \ No newline at end of file diff --git a/utils/techutils/techutils.go b/utils/techutils/techutils.go index 327861372..cd975ae8c 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -264,8 +264,8 @@ var technologiesData = map[Technology]TechData{ }, Nuget: { formal: "NuGet", - indicators: []string{".sln", ".slnx", ".csproj"}, - packageDescriptors: []string{".sln", ".slnx", ".csproj"}, + indicators: []string{".sln", ".slnx", ".csproj", ".fsproj", ".vbproj"}, + packageDescriptors: []string{".sln", ".slnx", ".csproj", ".fsproj", ".vbproj"}, // .NET CLI is used for NuGet projects execCommand: "dotnet", packageInstallationCommand: "add",