From 378d360fe8fc0f30b0490149b0e831deb0cd7b21 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Wed, 9 Sep 2026 10:30:53 +0300 Subject: [PATCH 1/4] Add Central Package Management (CPM) support When a PackageReference has no inline version, before giving up: check for a local VersionOverride attribute first (wins over CPM entirely if present), otherwise walk up from the project's directory toward the repo root looking for the nearest Directory.Packages.props - the same resolution NuGet itself uses, so nested/multiple CPM files naturally resolve to whichever governs that specific project ("closest wins"). The matching PackageVersion entry there is patched instead of the evidence file; the evidenced project's own lock file is still regenerated afterward, since its resolved version now depends on that central file. NoInlineVersionFixNotSupported now only covers what's genuinely left unresolvable: a governing file that doesn't declare the package, or no Directory.Packages.props found in the ancestor chain at all. Sibling projects that share the same governing file but weren't evidenced are not proactively restored - a known scope boundary, not a gap: their own lock files can go stale relative to the bumped central version until they're separately evidenced and fixed. --- .../packageupdaters/nugetpackageupdater.go | 132 ++++++++++++- .../nugetpackageupdater_test.go | 181 ++++++++++++++++++ remediation/sca/packageupdaters/types.go | 11 +- .../Cpm/Directory.Packages.props | 8 + .../Cpm/Nested/Directory.Packages.props | 8 + .../Cpm/Nested/NestedProj/NestedProj.csproj | 12 ++ .../Cpm/Nested/NestedProj/packages.lock.json | 13 ++ .../Cpm/Proj/Proj.csproj | 13 ++ .../Cpm/Proj/packages.lock.json | 13 ++ .../ProjWithOverride/ProjWithOverride.csproj | 12 ++ .../Cpm/ProjWithOverride/packages.lock.json | 13 ++ 11 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Directory.Packages.props create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/Directory.Packages.props create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/NestedProj.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Nested/NestedProj/packages.lock.json create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/Proj.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/Proj/packages.lock.json create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/ProjWithOverride.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/Cpm/ProjWithOverride/packages.lock.json diff --git a/remediation/sca/packageupdaters/nugetpackageupdater.go b/remediation/sca/packageupdaters/nugetpackageupdater.go index a1a170b5d..e3ffdc8c1 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater.go @@ -27,8 +27,13 @@ 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*["'])[^"']*(["'])` + + nugetPackageVersionElementPattern = `(?s)]*/>|]*[^/]>.*?` + nugetPackageVersionKeyAttrPattern = `(?i)\bInclude\s*=\s*["']%s["']` + nugetDirectoryPackagesPropsName = "Directory.Packages.props" nugetLockFileName = "packages.lock.json" nugetObjDirName = "obj" @@ -122,9 +127,13 @@ 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 fixErr != nil { + var unsupportedErr *ErrUnsupportedFix + if errors.As(fixErr, &unsupportedErr) && unsupportedErr.ErrorType == NoInlineVersionFixNotSupported { + return n.fixViaDirectoryPackagesProps(projectFilePath, packageName, fixedVersion, originalWd) + } + return fmt.Errorf("%w in %s", fixErr, projectFilePath) } //#nosec G703 G306 -- projectFilePath from scan workflow; 0644 for VCS-tracked sources. @@ -164,6 +173,59 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag return nil } +func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, packageName, fixedVersion, originalWd string) error { + unsupported := &ErrUnsupportedFix{ + PackageName: packageName, + FixedVersion: fixedVersion, + ErrorType: NoInlineVersionFixNotSupported, + } + + propsPath, originalProps, err := resolveDirectoryPackagesProps(filepath.Dir(projectFilePath), originalWd) + if err != nil { + return fmt.Errorf("%w in %s", err, projectFilePath) + } + if propsPath == "" { + return unsupported + } + + updatedProps, fixedAny := updatePackageVersionEntry(originalProps, packageName, fixedVersion) + if !fixedAny { + return unsupported + } + + //#nosec G703 G306 -- propsPath resolved from descriptor discovery in the scanned repository. + if err = os.WriteFile(propsPath, updatedProps, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", propsPath, err) + } + + projectDir := filepath.Dir(projectFilePath) + lockFilePath := filepath.Join(projectDir, nugetLockFileName) + //#nosec G304 -- lockFilePath is derived from projectFilePath, itself from descriptor discovery. + originalLockFile, err := os.ReadFile(lockFilePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return rollbackProjectFile(propsPath, originalProps, fmt.Errorf("failed to read %s: %w", lockFilePath, err)) + } + + lockFileTracked, checkErr := IsFileTrackedByGit(lockFilePath, 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(propsPath, originalProps, lockFilePath, originalLockFile, err) + } + return nil +} + func (n *NugetPackageUpdater) runDotnetRestore(projectFilePath string) error { objDir := filepath.Join(filepath.Dir(projectFilePath), nugetObjDirName) objDirExisted := dirExists(objDir) @@ -227,6 +289,7 @@ 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) var fixedAny, foundWithoutVersion bool updatedContent := element.ReplaceAllFunc(content, func(match []byte) []byte { @@ -240,6 +303,9 @@ 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}")) default: foundWithoutVersion = true return match @@ -258,3 +324,59 @@ 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 +} + +func resolveDirectoryPackagesProps(projectDir, repoRoot 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(projectDir) + if err != nil { + return "", nil, fmt.Errorf("failed to resolve absolute path for %s: %w", projectDir, err) + } + + for { + candidate := filepath.Join(dir, nugetDirectoryPackagesPropsName) + //#nosec G304 -- candidate is built from projectDir/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 + } +} diff --git a/remediation/sca/packageupdaters/nugetpackageupdater_test.go b/remediation/sca/packageupdaters/nugetpackageupdater_test.go index cff5c3f83..223cb63fb 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater_test.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater_test.go @@ -922,3 +922,184 @@ func TestUpdatePackageReferenceVersionRejectsMixedInlineAndNonInline(t *testing. assert.True(t, errors.As(err, &unsupportedErr)) assert.Equal(t, NoInlineVersionFixNotSupported, 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 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 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) +} diff --git a/remediation/sca/packageupdaters/types.go b/remediation/sca/packageupdaters/types.go index 1a16a3917..03d37c839 100644 --- a/remediation/sca/packageupdaters/types.go +++ b/remediation/sca/packageupdaters/types.go @@ -24,11 +24,10 @@ 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 covers a PackageReference with no inline version, no + // VersionOverride, and no resolvable entry in the nearest Directory.Packages.props (or no such + // file at all) - it may still be supplied via Directory.Build.props, a farther/unrelated + // centrally-managed file, or be an SDK-implicit reference. NoInlineVersionFixNotSupported UnsupportedErrorType = "NoInlineVersionFixNotSupported" UnsupportedFixReason UnsupportedErrorType = "UnsupportedFixReason" ) @@ -45,7 +44,7 @@ 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 entry found)", 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/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==" + } + } + } +} From 9aeff2a066501890f136e148844172a002c47b4b Mon Sep 17 00:00:00 2001 From: Or Toren Date: Wed, 9 Sep 2026 11:01:44 +0300 Subject: [PATCH 2/4] Address NuGet CPM review: VersionOverride element, imports, and repo-root bounds. Co-authored-by: Cursor --- .../packageupdaters/nugetpackageupdater.go | 147 +++++-- .../nugetpackageupdater_test.go | 380 ++++++++++++++++++ remediation/sca/packageupdaters/types.go | 5 +- 3 files changed, 487 insertions(+), 45 deletions(-) diff --git a/remediation/sca/packageupdaters/nugetpackageupdater.go b/remediation/sca/packageupdaters/nugetpackageupdater.go index e3ffdc8c1..7fe0f71b4 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater.go @@ -27,13 +27,16 @@ 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)()[^<]*()` - nugetVersionOverrideAttrPattern = `(?is)(\bVersionOverride\s*=\s*["'])[^"']*(["'])` + nugetVersionAttrPattern = `(?is)(\bVersion\s*=\s*["'])[^"']*(["'])` + nugetVersionElementPattern = `(?is)()[^<]*()` + nugetVersionOverrideAttrPattern = `(?is)(\bVersionOverride\s*=\s*["'])[^"']*(["'])` + nugetVersionOverrideElementPattern = `(?is)()[^<]*()` nugetPackageVersionElementPattern = `(?s)]*/>|]*[^/]>.*?` nugetPackageVersionKeyAttrPattern = `(?i)\bInclude\s*=\s*["']%s["']` nugetDirectoryPackagesPropsName = "Directory.Packages.props" + nugetImportProjectAttrPattern = `(?i)]*\bProject\s*=\s*["']([^"']+)["']` + nugetManageCpmFalsePattern = `(?is)\s*false\s*` nugetLockFileName = "packages.lock.json" nugetObjDirName = "obj" @@ -141,36 +144,7 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag 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 + return n.restoreLockFileAfterWrite(projectFilePath, projectFilePath, originalProjectFile, originalWd, packageName, fixedVersion) } func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, packageName, fixedVersion, originalWd string) error { @@ -188,28 +162,43 @@ func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, pack return unsupported } - updatedProps, fixedAny := updatePackageVersionEntry(originalProps, packageName, fixedVersion) + absRepoRoot, err := filepath.Abs(originalWd) + if err != nil { + return fmt.Errorf("failed to resolve absolute path for %s: %w", originalWd, err) + } + + 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 -- propsPath resolved from descriptor discovery in the scanned repository. - if err = os.WriteFile(propsPath, updatedProps, 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", propsPath, err) + //#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) } - projectDir := filepath.Dir(projectFilePath) - lockFilePath := filepath.Join(projectDir, nugetLockFileName) + return n.restoreLockFileAfterWrite(projectFilePath, targetPath, originalContent, originalWd, packageName, fixedVersion) +} + +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) { return nil } - return rollbackProjectFile(propsPath, originalProps, fmt.Errorf("failed to read %s: %w", lockFilePath, err)) + return rollbackProjectFile(writtenPath, originalWritten, fmt.Errorf("failed to read %s: %w", lockFilePath, err)) } - lockFileTracked, checkErr := IsFileTrackedByGit(lockFilePath, originalWd) + 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 @@ -221,7 +210,7 @@ func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, pack 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(propsPath, originalProps, lockFilePath, originalLockFile, err) + return rollbackProjectFileAndLock(writtenPath, originalWritten, lockFilePath, originalLockFile, err) } return nil } @@ -290,6 +279,7 @@ func updatePackageReferenceVersion(content []byte, packageName, fixedVersion str 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 { @@ -306,6 +296,9 @@ func updatePackageReferenceVersion(content []byte, packageName, fixedVersion str 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 @@ -350,6 +343,65 @@ func updatePackageVersionEntry(content []byte, packageName, fixedVersion string) return updatedContent, fixedAny } +func resolvePackageVersionUpdate(propsPath string, propsContent []byte, packageName, fixedVersion, repoRoot string) (string, []byte, []byte, bool, error) { + if isCentralPackageManagementDisabled(propsContent) { + return "", nil, nil, false, nil + } + return searchPackageVersionUpdate(propsPath, propsContent, packageName, fixedVersion, repoRoot, map[string]struct{}{}) +} + +func isCentralPackageManagementDisabled(content []byte) bool { + return regexp.MustCompile(nugetManageCpmFalsePattern).Match(content) +} + +func searchPackageVersionUpdate(propsPath string, propsContent []byte, packageName, fixedVersion, repoRoot string, visited map[string]struct{}) (string, []byte, []byte, bool, error) { + absPath, err := filepath.Abs(propsPath) + if err != nil { + return "", nil, nil, false, fmt.Errorf("failed to resolve absolute path for %s: %w", propsPath, 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 := updatePackageVersionEntry(propsContent, packageName, fixedVersion) + if fixedAny { + return absPath, propsContent, updated, true, nil + } + + importAttr := regexp.MustCompile(nugetImportProjectAttrPattern) + for _, match := range importAttr.FindAllSubmatch(propsContent, -1) { + projectRef := string(match[1]) + 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 := searchPackageVersionUpdate(nextAbs, nextContent, packageName, fixedVersion, repoRoot, visited) + if searchErr != nil || ok { + return foundPath, original, updatedImported, ok, searchErr + } + } + return "", nil, nil, false, nil +} + func resolveDirectoryPackagesProps(projectDir, repoRoot string) (path string, content []byte, err error) { absRepoRoot, err := filepath.Abs(repoRoot) if err != nil { @@ -361,6 +413,9 @@ func resolveDirectoryPackagesProps(projectDir, repoRoot string) (path string, co } for { + if !isPathInsideRoot(absRepoRoot, dir) { + return "", nil, nil + } candidate := filepath.Join(dir, nugetDirectoryPackagesPropsName) //#nosec G304 -- candidate is built from projectDir/repoRoot, from descriptor discovery. candidateContent, readErr := os.ReadFile(candidate) @@ -380,3 +435,11 @@ func resolveDirectoryPackagesProps(projectDir, repoRoot string) (path string, co 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 223cb63fb..8f5bb1421 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater_test.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater_test.go @@ -1071,6 +1071,67 @@ func TestNugetUpdateDependencyNestedDirectoryPackagesPropsClosestWins(t *testing 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") @@ -1103,3 +1164,322 @@ func TestNugetUpdateDependencyDirectoryPackagesPropsWithoutMatchingEntry(t *test 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 := resolveDirectoryPackagesProps(projectDir, repoRoot) + 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 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 := filepath.Join("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`) +} diff --git a/remediation/sca/packageupdaters/types.go b/remediation/sca/packageupdaters/types.go index 03d37c839..9085d091c 100644 --- a/remediation/sca/packageupdaters/types.go +++ b/remediation/sca/packageupdaters/types.go @@ -25,9 +25,8 @@ type UnsupportedErrorType string const ( IndirectDependencyFixNotSupported UnsupportedErrorType = "IndirectDependencyFixNotSupported" // NoInlineVersionFixNotSupported covers a PackageReference with no inline version, no - // VersionOverride, and no resolvable entry in the nearest Directory.Packages.props (or no such - // file at all) - it may still be supplied via Directory.Build.props, a farther/unrelated - // centrally-managed file, or be an SDK-implicit reference. + // VersionOverride, and no resolvable PackageVersion in the nearest Directory.Packages.props + // or a file it Imports - including when that props file disables Central Package Management. NoInlineVersionFixNotSupported UnsupportedErrorType = "NoInlineVersionFixNotSupported" UnsupportedFixReason UnsupportedErrorType = "UnsupportedFixReason" ) From d6af50978679eecb34252a67eec631373ca82944 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Wed, 9 Sep 2026 13:02:02 +0300 Subject: [PATCH 3/4] Make CPM/Directory.Build.props fixes reachable, close macro-import gap Evidence for a CPM- or Directory.Build.props-resolved package only ever points at packages.lock.json, never the .csproj - the version text never appears there at all. techutils' Nuget descriptor list didn't recognize lock files, so that evidence was discarded before reaching any updater. Added it there, and taught collectProjectFilePaths to resolve a lock-file evidence path to the sibling project file in the same directory. Added a Directory.Build.props/.targets fallback, tried after Central Package Management comes back unresolved, reusing the same walk-up and Import-following logic (generalized from the CPM-only version). Broadened the fallback trigger to any failure from the inline-version check, not just "found without a version" - a package injected purely via Directory.Build.props has no local PackageReference to find at all, which previously returned a plain "not found" that never attempted the fallback chain. Resolved $(MSBuildThisFileDirectory) in Import paths before checking for unresolvable macros, since it's a trivial, common case that doesn't need full MSBuild evaluation. --- .../packageupdaters/nugetpackageupdater.go | 144 ++++++++++++++---- .../nugetpackageupdater_test.go | 142 ++++++++++++++++- .../BuildProps/Directory.Build.props | 5 + .../BuildProps/Proj/Proj.csproj | 8 + .../BuildProps/Proj/packages.lock.json | 13 ++ .../CpmMacroImport/Directory.Packages.props | 6 + .../CpmMacroImport/Proj/Proj.csproj | 12 ++ .../CpmMacroImport/Proj/packages.lock.json | 13 ++ .../CpmMacroImport/Shared.props | 5 + utils/techutils/techutils.go | 10 +- 10 files changed, 328 insertions(+), 30 deletions(-) create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Directory.Build.props create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/Proj.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/BuildProps/Proj/packages.lock.json create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Directory.Packages.props create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/Proj.csproj create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Proj/packages.lock.json create mode 100644 tests/testdata/projects/package-managers/nuget/remediation-packageupdaters/CpmMacroImport/Shared.props diff --git a/remediation/sca/packageupdaters/nugetpackageupdater.go b/remediation/sca/packageupdaters/nugetpackageupdater.go index 7fe0f71b4..098a536e8 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater.go @@ -32,11 +32,14 @@ const ( nugetVersionOverrideAttrPattern = `(?is)(\bVersionOverride\s*=\s*["'])[^"']*(["'])` nugetVersionOverrideElementPattern = `(?is)()[^<]*()` - nugetPackageVersionElementPattern = `(?s)]*/>|]*[^/]>.*?` - nugetPackageVersionKeyAttrPattern = `(?i)\bInclude\s*=\s*["']%s["']` - nugetDirectoryPackagesPropsName = "Directory.Packages.props" - nugetImportProjectAttrPattern = `(?i)]*\bProject\s*=\s*["']([^"']+)["']` - nugetManageCpmFalsePattern = `(?is)\s*false\s*` + nugetPackageVersionElementPattern = `(?s)]*/>|]*[^/]>.*?` + nugetPackageVersionKeyAttrPattern = `(?i)\bInclude\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*` + nugetMSBuildThisFileDirectoryMacro = "$(MSBuildThisFileDirectory)" nugetLockFileName = "packages.lock.json" nugetObjDirName = "obj" @@ -104,15 +107,48 @@ 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) + } + for _, entry := range entries { + if !entry.IsDir() && hasNugetProjectFileSuffix(entry.Name()) { + return filepath.Join(dir, entry.Name()), nil + } + } + return "", nil +} + func hasNugetProjectFileSuffix(path string) bool { lowerPath := strings.ToLower(path) for _, suffix := range nugetProjectFileSuffixes { @@ -132,11 +168,7 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag updatedProjectFile, fixErr := updatePackageReferenceVersion(originalProjectFile, packageName, fixedVersion) if fixErr != nil { - var unsupportedErr *ErrUnsupportedFix - if errors.As(fixErr, &unsupportedErr) && unsupportedErr.ErrorType == NoInlineVersionFixNotSupported { - return n.fixViaDirectoryPackagesProps(projectFilePath, packageName, fixedVersion, originalWd) - } - return fmt.Errorf("%w in %s", fixErr, projectFilePath) + return n.fixViaVersionSource(projectFilePath, packageName, fixedVersion, originalWd) } //#nosec G703 G306 -- projectFilePath from scan workflow; 0644 for VCS-tracked sources. @@ -147,6 +179,19 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag 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 (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, packageName, fixedVersion, originalWd string) error { unsupported := &ErrUnsupportedFix{ PackageName: packageName, @@ -154,7 +199,7 @@ func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, pack ErrorType: NoInlineVersionFixNotSupported, } - propsPath, originalProps, err := resolveDirectoryPackagesProps(filepath.Dir(projectFilePath), originalWd) + propsPath, originalProps, err := resolveDirectoryFile(filepath.Dir(projectFilePath), originalWd, nugetDirectoryPackagesPropsName) if err != nil { return fmt.Errorf("%w in %s", err, projectFilePath) } @@ -183,6 +228,44 @@ func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, pack 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 + } + + targetPath, originalContent, updatedContent, fixedAny, searchErr := searchVersionUpdate(buildFilePath, buildFileContent, packageName, fixedVersion, absRepoRoot, tryFixPackageReference, 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. @@ -347,17 +430,22 @@ func resolvePackageVersionUpdate(propsPath string, propsContent []byte, packageN if isCentralPackageManagementDisabled(propsContent) { return "", nil, nil, false, nil } - return searchPackageVersionUpdate(propsPath, propsContent, packageName, fixedVersion, repoRoot, map[string]struct{}{}) + return searchVersionUpdate(propsPath, propsContent, packageName, fixedVersion, repoRoot, updatePackageVersionEntry, map[string]struct{}{}) } func isCentralPackageManagementDisabled(content []byte) bool { return regexp.MustCompile(nugetManageCpmFalsePattern).Match(content) } -func searchPackageVersionUpdate(propsPath string, propsContent []byte, packageName, fixedVersion, repoRoot string, visited map[string]struct{}) (string, []byte, []byte, bool, error) { - absPath, err := filepath.Abs(propsPath) +func tryFixPackageReference(content []byte, packageName, fixedVersion string) ([]byte, bool) { + updated, err := updatePackageReferenceVersion(content, packageName, fixedVersion) + return updated, err == nil +} + +func searchVersionUpdate(path string, content []byte, packageName, fixedVersion, repoRoot string, tryFix func([]byte, string, string) ([]byte, bool), 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", propsPath, err) + 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 @@ -367,14 +455,18 @@ func searchPackageVersionUpdate(propsPath string, propsContent []byte, packageNa } visited[absPath] = struct{}{} - updated, fixedAny := updatePackageVersionEntry(propsContent, packageName, fixedVersion) + updated, fixedAny := tryFix(content, packageName, fixedVersion) if fixedAny { - return absPath, propsContent, updated, true, nil + return absPath, content, updated, true, nil } importAttr := regexp.MustCompile(nugetImportProjectAttrPattern) - for _, match := range importAttr.FindAllSubmatch(propsContent, -1) { + 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 } @@ -394,7 +486,7 @@ func searchPackageVersionUpdate(propsPath string, propsContent []byte, packageNa } return "", nil, nil, false, fmt.Errorf("failed to read %s: %w", nextAbs, readErr) } - foundPath, original, updatedImported, ok, searchErr := searchPackageVersionUpdate(nextAbs, nextContent, packageName, fixedVersion, repoRoot, visited) + foundPath, original, updatedImported, ok, searchErr := searchVersionUpdate(nextAbs, nextContent, packageName, fixedVersion, repoRoot, tryFix, visited) if searchErr != nil || ok { return foundPath, original, updatedImported, ok, searchErr } @@ -402,22 +494,22 @@ func searchPackageVersionUpdate(propsPath string, propsContent []byte, packageNa return "", nil, nil, false, nil } -func resolveDirectoryPackagesProps(projectDir, repoRoot string) (path string, content []byte, err error) { +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(projectDir) + dir, err := filepath.Abs(startDir) if err != nil { - return "", nil, fmt.Errorf("failed to resolve absolute path for %s: %w", projectDir, err) + 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, nugetDirectoryPackagesPropsName) - //#nosec G304 -- candidate is built from projectDir/repoRoot, from descriptor discovery. + 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 diff --git a/remediation/sca/packageupdaters/nugetpackageupdater_test.go b/remediation/sca/packageupdaters/nugetpackageupdater_test.go index 8f5bb1421..41281014e 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater_test.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater_test.go @@ -970,6 +970,96 @@ func TestNugetUpdateDependencyResolvesSingleDirectoryPackagesProps(t *testing.T) 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") @@ -1175,7 +1265,7 @@ func TestResolveDirectoryPackagesPropsDoesNotWalkOutsideRepoRoot(t *testing.T) { outsideProps := filepath.Join(outsideRoot, "Directory.Packages.props") assert.NoError(t, os.WriteFile(outsideProps, []byte(``), 0644)) - path, content, err := resolveDirectoryPackagesProps(projectDir, repoRoot) + path, content, err := resolveDirectoryFile(projectDir, repoRoot, nugetDirectoryPackagesPropsName) assert.NoError(t, err) assert.Empty(t, path) assert.Empty(t, content) @@ -1260,6 +1350,56 @@ func TestNugetUpdateDependencyFollowsDirectoryPackagesPropsImport(t *testing.T) 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") 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/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/techutils/techutils.go b/utils/techutils/techutils.go index cd975ae8c..37371eff7 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", From e1a4ead3c91ac5df00493096278cf08eee1b3239 Mon Sep 17 00:00:00 2001 From: Or Toren Date: Thu, 17 Sep 2026 11:59:29 +0300 Subject: [PATCH 4/4] Harden NuGet CPM fixes: skip mixed refs, prefer csproj evidence, and fix Join lint. Stop falling through to Directory.Packages.props when a project mixes inline and versionless PackageReferences, keep lock-file evidence from winning over project files, and drop single-argument filepath.Join calls that fail gocritic. Co-authored-by: Cursor --- .../packageupdaters/nugetpackageupdater.go | 115 ++++- .../nugetpackageupdater_test.go | 431 +++++++++++++++++- remediation/sca/packageupdaters/types.go | 14 +- utils/results/common.go | 24 +- utils/results/common_test.go | 10 + utils/techutils/techutils.go | 4 + 6 files changed, 565 insertions(+), 33 deletions(-) diff --git a/remediation/sca/packageupdaters/nugetpackageupdater.go b/remediation/sca/packageupdaters/nugetpackageupdater.go index 098a536e8..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" @@ -32,13 +33,14 @@ const ( nugetVersionOverrideAttrPattern = `(?is)(\bVersionOverride\s*=\s*["'])[^"']*(["'])` nugetVersionOverrideElementPattern = `(?is)()[^<]*()` - nugetPackageVersionElementPattern = `(?s)]*/>|]*[^/]>.*?` - nugetPackageVersionKeyAttrPattern = `(?i)\bInclude\s*=\s*["']%s["']` + 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" @@ -141,12 +143,21 @@ func findProjectFileInDir(dir string) (string, error) { 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()) { - return filepath.Join(dir, entry.Name()), nil + matches = append(matches, filepath.Join(dir, entry.Name())) } } - return "", nil + 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 { @@ -167,6 +178,9 @@ func (n *NugetPackageUpdater) fixVulnerabilityAndRestore(projectFilePath, packag } 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) } @@ -192,6 +206,11 @@ func isUnsupportedNoInlineVersion(err error) bool { 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, @@ -212,6 +231,10 @@ func (n *NugetPackageUpdater) fixViaDirectoryPackagesProps(projectFilePath, pack 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) @@ -249,19 +272,21 @@ func (n *NugetPackageUpdater) fixViaDirectoryBuildProps(projectFilePath, package continue } - targetPath, originalContent, updatedContent, fixedAny, searchErr := searchVersionUpdate(buildFilePath, buildFileContent, packageName, fixedVersion, absRepoRoot, tryFixPackageReference, map[string]struct{}{}) - if searchErr != nil { - return fmt.Errorf("%w in %s", searchErr, projectFilePath) - } - if !fixedAny { - 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) + //#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 n.restoreLockFileAfterWrite(projectFilePath, targetPath, originalContent, originalWd, packageName, fixedVersion) } return unsupported } @@ -388,6 +413,13 @@ func updatePackageReferenceVersion(content []byte, packageName, fixedVersion str } }) + if foundWithoutVersion && fixedAny { + return nil, &ErrUnsupportedFix{ + PackageName: packageName, + FixedVersion: fixedVersion, + ErrorType: MixedInlineAndNonInlineVersionFixNotSupported, + } + } if foundWithoutVersion { return nil, &ErrUnsupportedFix{ PackageName: packageName, @@ -426,23 +458,63 @@ func updatePackageVersionEntry(content []byte, packageName, fixedVersion string) 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, updatePackageVersionEntry, map[string]struct{}{}) + return searchVersionUpdate(propsPath, propsContent, packageName, fixedVersion, repoRoot, tryFixPackageVersionEntry, map[string]struct{}{}) } func isCentralPackageManagementDisabled(content []byte) bool { return regexp.MustCompile(nugetManageCpmFalsePattern).Match(content) } -func tryFixPackageReference(content []byte, packageName, fixedVersion string) ([]byte, bool) { +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) - return updated, err == nil + if err == nil { + return updated, true, nil + } + if isMixedInlineAndNonInline(err) { + return nil, false, err + } + return nil, false, nil } -func searchVersionUpdate(path string, content []byte, packageName, fixedVersion, repoRoot string, tryFix func([]byte, string, string) ([]byte, bool), visited map[string]struct{}) (string, []byte, []byte, bool, error) { +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) @@ -455,7 +527,10 @@ func searchVersionUpdate(path string, content []byte, packageName, fixedVersion, } visited[absPath] = struct{}{} - updated, fixedAny := tryFix(content, packageName, fixedVersion) + updated, fixedAny, tryErr := tryFix(content, packageName, fixedVersion) + if tryErr != nil { + return "", nil, nil, false, tryErr + } if fixedAny { return absPath, content, updated, true, nil } diff --git a/remediation/sca/packageupdaters/nugetpackageupdater_test.go b/remediation/sca/packageupdaters/nugetpackageupdater_test.go index 41281014e..f61c5c8b9 100644 --- a/remediation/sca/packageupdaters/nugetpackageupdater_test.go +++ b/remediation/sca/packageupdaters/nugetpackageupdater_test.go @@ -920,7 +920,7 @@ func TestUpdatePackageReferenceVersionRejectsMixedInlineAndNonInline(t *testing. assert.Nil(t, updated) var unsupportedErr *ErrUnsupportedFix assert.True(t, errors.As(err, &unsupportedErr)) - assert.Equal(t, NoInlineVersionFixNotSupported, unsupportedErr.ErrorType) + assert.Equal(t, MixedInlineAndNonInlineVersionFixNotSupported, unsupportedErr.ErrorType) } func TestNugetUpdateDependencyResolvesSingleDirectoryPackagesProps(t *testing.T) { @@ -1417,7 +1417,7 @@ func TestNugetUpdateDependencySkipsWhenManagePackageVersionsCentrallyFalse(t *te assert.NoError(t, os.Chdir(currDir)) }() - disabledDir := filepath.Join("CpmDisabled") + disabledDir := "CpmDisabled" assert.NoError(t, os.MkdirAll(filepath.Join(disabledDir, "Proj"), 0755)) propsContent := []byte(` @@ -1623,3 +1623,430 @@ func TestNugetUpdateDependencyPackageVersionChildElement(t *testing.T) { 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 9085d091c..876a3875c 100644 --- a/remediation/sca/packageupdaters/types.go +++ b/remediation/sca/packageupdaters/types.go @@ -25,10 +25,12 @@ type UnsupportedErrorType string const ( IndirectDependencyFixNotSupported UnsupportedErrorType = "IndirectDependencyFixNotSupported" // NoInlineVersionFixNotSupported covers a PackageReference with no inline version, no - // VersionOverride, and no resolvable PackageVersion in the nearest Directory.Packages.props - // or a file it Imports - including when that props file disables Central Package Management. - NoInlineVersionFixNotSupported UnsupportedErrorType = "NoInlineVersionFixNotSupported" - UnsupportedFixReason UnsupportedErrorType = "UnsupportedFixReason" + // 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 { @@ -43,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' - could not resolve a version to fix (no inline attribute, VersionOverride, or matching Directory.Packages.props entry found)", 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/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 37371eff7..8491b4c0f 100644 --- a/utils/techutils/techutils.go +++ b/utils/techutils/techutils.go @@ -460,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 {