diff --git a/checks/raw/pinned_dependencies.go b/checks/raw/pinned_dependencies.go index 0e6896d8fcb..4f44f05d645 100644 --- a/checks/raw/pinned_dependencies.go +++ b/checks/raw/pinned_dependencies.go @@ -29,7 +29,8 @@ import ( "github.com/ossf/scorecard/v5/checks/fileparser" sce "github.com/ossf/scorecard/v5/errors" "github.com/ossf/scorecard/v5/finding" - "github.com/ossf/scorecard/v5/internal/csproj" + "github.com/ossf/scorecard/v5/internal/dotnet/csproj" + "github.com/ossf/scorecard/v5/internal/dotnet/properties" "github.com/ossf/scorecard/v5/remediation" ) @@ -38,6 +39,11 @@ type dotnetCsprojLockedData struct { LockedModeSet bool } +type nugetPostProcessData struct { + CsprojConfigs []dotnetCsprojLockedData + CpmConfig properties.CentralPackageManagementConfig +} + // PinningDependencies checks for (un)pinned dependencies. func PinningDependencies(c *checker.CheckRequest) (checker.PinningDependenciesData, error) { var results checker.PinningDependenciesData @@ -67,14 +73,90 @@ func PinningDependencies(c *checker.CheckRequest) (checker.PinningDependenciesDa return checker.PinningDependenciesData{}, err } - if unpinnedNugetDependencies := getUnpinnedNugetDependencies(&results); len(unpinnedNugetDependencies) > 0 { - if err := processCsprojLockedMode(c, unpinnedNugetDependencies); err != nil { - return checker.PinningDependenciesData{}, err - } + // Nuget Post Processing + if err := postProcessNugetDependencies(c, &results); err != nil { + return checker.PinningDependenciesData{}, err } + return results, nil } +func postProcessNugetDependencies(c *checker.CheckRequest, + pinningDependenciesData *checker.PinningDependenciesData, +) error { + unpinnedDependencies := getUnpinnedNugetDependencies(pinningDependenciesData) + if len(unpinnedDependencies) == 0 { + return nil + } + var nugetPostProcessData nugetPostProcessData + if err := retrieveNugetCentralPackageManagement(c, &nugetPostProcessData); err != nil { + return err + } + if err := retrieveCsprojConfig(c, &nugetPostProcessData); err != nil { + return err + } + if nugetPostProcessData.CpmConfig.IsCPMEnabled { + collectPostProcessNugetCPMDependencies(unpinnedDependencies, &nugetPostProcessData) + } else { + collectPostProcessNugetCsprojDependencies(unpinnedDependencies, &nugetPostProcessData) + } + + return nil +} + +func collectPostProcessNugetCPMDependencies(unpinnedNugetDependencies []*checker.Dependency, + postProcessingData *nugetPostProcessData, +) { + packageVersions := postProcessingData.CpmConfig.PackageVersions + + numUnfixedVersions, unfixedVersions := countUnfixedVersions(packageVersions) + // if all dependencies are fixed to specific versions, pin all dependencies + if numUnfixedVersions == 0 { + pinAllNugetDependencies(unpinnedNugetDependencies) + return + } + // if some or all dependencies are not fixed to specific versions, update the remediation + for i := range unpinnedNugetDependencies { + (unpinnedNugetDependencies)[i].Remediation.Text = (unpinnedNugetDependencies)[i].Remediation.Text + + ": some of dependency versions are not fixes to specific versions: " + unfixedVersions + } +} + +func retrieveNugetCentralPackageManagement(c *checker.CheckRequest, nugetPostProcessData *nugetPostProcessData) error { + if err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{ + Pattern: "Directory.*.props", + CaseSensitive: false, + }, processDirectoryPropsFile, nugetPostProcessData, c.Dlogger); err != nil { + return err + } + + return nil +} + +func processDirectoryPropsFile(path string, content []byte, args ...interface{}) (bool, error) { + pdata, ok := args[0].(*nugetPostProcessData) + if !ok { + // panic if it is not correct type + panic(fmt.Sprintf("expected type nugetPostProcessData, got %v", reflect.TypeOf(args[0]))) + } + + cpmConfig, err := properties.GetCentralPackageManagementConfig(path, content) + if err != nil { + dl, ok := args[1].(checker.DetailLogger) + if !ok { + // panic if it is not correct type + panic(fmt.Sprintf("expected type checker.DetailLogger, got %v", reflect.TypeOf(args[1]))) + } + + dl.Warn(&checker.LogMessage{ + Text: fmt.Sprintf("malformed properties file: %v", err), + }) + return true, nil + } + pdata.CpmConfig = cpmConfig + return false, nil +} + func getUnpinnedNugetDependencies(pinningDependenciesData *checker.PinningDependenciesData) []*checker.Dependency { var unpinnedNugetDependencies []*checker.Dependency nugetDependencies := getDependenciesByType(pinningDependenciesData, checker.DependencyUseTypeNugetCommand) @@ -98,30 +180,25 @@ func getDependenciesByType(p *checker.PinningDependenciesData, return deps } -func processCsprojLockedMode(c *checker.CheckRequest, dependencies []*checker.Dependency) error { - csprojDeps, err := collectCsprojLockedModeData(c) - if err != nil { - return err - } - unlockedCsprojDeps, unlockedPath := countUnlocked(csprojDeps) - - // none of the csproject files set RestoreLockedMode. Keep the same status of the nuget dependencies - if unlockedCsprojDeps == len(csprojDeps) { - return nil - } - - // all csproj files set RestoreLockedMode, update the dependency pinning status of all nuget dependencies to pinned - if unlockedCsprojDeps == 0 { - pinAllNugetDependencies(dependencies) - } else { +func collectPostProcessNugetCsprojDependencies(unpinnedNugetDependencies []*checker.Dependency, + postProcessingData *nugetPostProcessData, +) { + unlockedCsprojDeps, unlockedPath := countUnlocked(postProcessingData.CsprojConfigs) + switch unlockedCsprojDeps { + case len(postProcessingData.CsprojConfigs): + // none of the csproject files set RestoreLockedMode. Keep the same status of the nuget dependencies + return + case 0: + // all csproj files set RestoreLockedMode, update the dependency pinning status of all nuget dependencies to pinned + pinAllNugetDependencies(unpinnedNugetDependencies) + default: // only some csproj files are locked, keep the same status of the nuget dependencies but create a remediation - for i := range dependencies { - (dependencies)[i].Remediation.Text = (dependencies)[i].Remediation.Text + + for i := range unpinnedNugetDependencies { + (unpinnedNugetDependencies)[i].Remediation.Text = (unpinnedNugetDependencies)[i].Remediation.Text + ": some of your csproj files set the RestoreLockedMode property to true, " + "while other do not set it: " + unlockedPath } } - return nil } func pinAllNugetDependencies(dependencies []*checker.Dependency) { @@ -133,16 +210,15 @@ func pinAllNugetDependencies(dependencies []*checker.Dependency) { } } -func collectCsprojLockedModeData(c *checker.CheckRequest) ([]dotnetCsprojLockedData, error) { - var csprojDeps []dotnetCsprojLockedData +func retrieveCsprojConfig(c *checker.CheckRequest, nugetPostProcessData *nugetPostProcessData) error { if err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{ Pattern: "*.csproj", CaseSensitive: false, - }, analyseCsprojLockedMode, &csprojDeps, c.Dlogger); err != nil { - return nil, err + }, analyseCsprojLockedMode, &nugetPostProcessData.CsprojConfigs, c.Dlogger); err != nil { + return err } - return csprojDeps, nil + return nil } func analyseCsprojLockedMode(path string, content []byte, args ...interface{}) (bool, error) { @@ -186,6 +262,17 @@ func countUnlocked(csprojFiles []dotnetCsprojLockedData) (int, string) { return len(unlockedPaths), strings.Join(unlockedPaths, ", ") } +func countUnfixedVersions(packages []properties.NugetPackage) (int, string) { + var unfixedVersions []string + + for i := range packages { + if !packages[i].IsFixed { + unfixedVersions = append(unfixedVersions, packages[i].Version) + } + } + return len(unfixedVersions), strings.Join(unfixedVersions, ", ") +} + func dataAsPinnedDependenciesPointer(data interface{}) *checker.PinningDependenciesData { pdata, ok := data.(*checker.PinningDependenciesData) if !ok { diff --git a/checks/raw/pinned_dependencies_test.go b/checks/raw/pinned_dependencies_test.go index 53c3962d753..6d5e5161f40 100644 --- a/checks/raw/pinned_dependencies_test.go +++ b/checks/raw/pinned_dependencies_test.go @@ -28,6 +28,7 @@ import ( "github.com/ossf/scorecard/v5/checker" mockrepo "github.com/ossf/scorecard/v5/clients/mockclients" "github.com/ossf/scorecard/v5/finding" + "github.com/ossf/scorecard/v5/internal/dotnet/properties" "github.com/ossf/scorecard/v5/remediation" scut "github.com/ossf/scorecard/v5/utests" ) @@ -2170,22 +2171,22 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { tests := []struct { name string filenames []string - stagedDependencies []*checker.Dependency - outcomeDependencies []*checker.Dependency + stagedDependencies []checker.Dependency + outcomeDependencies []checker.Dependency expectError bool }{ { name: "pinned by command and 'locked mode' disabled implicitly", filenames: []string{"./dotnet-locked-mode-disabled-implicitly.csproj"}, expectError: false, - stagedDependencies: []*checker.Dependency{ + stagedDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(true), Remediation: nil, }, }, - outcomeDependencies: []*checker.Dependency{ + outcomeDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(true), @@ -2197,7 +2198,7 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { name: "unpinned by command and 'locked mode' disabled implicitly", filenames: []string{"./dotnet-locked-mode-disabled-implicitly.csproj"}, expectError: false, - stagedDependencies: []*checker.Dependency{ + stagedDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(false), @@ -2206,7 +2207,7 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { }, }, }, - outcomeDependencies: []*checker.Dependency{ + outcomeDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(false), @@ -2220,7 +2221,7 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { name: "unpinned by command and 'locked mode' enabled", filenames: []string{"./dotnet-locked-mode-enabled.csproj"}, expectError: false, - stagedDependencies: []*checker.Dependency{ + stagedDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(false), @@ -2229,7 +2230,7 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { }, }, }, - outcomeDependencies: []*checker.Dependency{ + outcomeDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(true), @@ -2241,14 +2242,14 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { name: "unpinned by command and 'locked mode' enabled and disabled in different csproj files", filenames: []string{"./dotnet-locked-mode-enabled.csproj", "./dotnet-locked-mode-disabled.csproj"}, expectError: false, - stagedDependencies: []*checker.Dependency{ + stagedDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(false), Remediation: &finding.Remediation{Text: "remediate"}, }, }, - outcomeDependencies: []*checker.Dependency{ + outcomeDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(false), @@ -2256,6 +2257,25 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { }, }, }, + { + name: "unpinned by command and error in csproj files", + filenames: []string{"./dotnet-invalid.csproj"}, + expectError: true, + stagedDependencies: []checker.Dependency{ + { + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(false), + Remediation: &finding.Remediation{Text: "remediate"}, + }, + }, + outcomeDependencies: []checker.Dependency{ + { + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(false), + Remediation: &finding.Remediation{Text: "remediate"}, + }, + }, + }, } for _, tt := range tests { @@ -2271,12 +2291,18 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { mockRepoClient.EXPECT().GetFileReader(gomock.Any()).AnyTimes().DoAndReturn(func(file string) (io.ReadCloser, error) { return os.Open(filepath.Join("testdata", file)) }) + testPinningData := checker.PinningDependenciesData{ + Dependencies: tt.stagedDependencies, + } + + dl := scut.TestDetailLogger{} req := checker.CheckRequest{ RepoClient: mockRepoClient, + Dlogger: &dl, } - err := processCsprojLockedMode(&req, tt.stagedDependencies) + err := postProcessNugetDependencies(&req, &testPinningData) if err != nil { if !tt.expectError { t.Error(err.Error()) @@ -2290,6 +2316,138 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { } } +func TestCollectPostProcessNugetCPMDependencies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputNugetDependencies []*checker.Dependency + data *nugetPostProcessData + expectedOutputNugetDependencies []checker.Dependency + }{ + { + name: "All dependencies are fixed", + inputNugetDependencies: []*checker.Dependency{ + { + Name: newString("dep1"), + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(false), + Remediation: &finding.Remediation{ + Text: "remediate", + }, + }, + }, + data: &nugetPostProcessData{ + CpmConfig: properties.CentralPackageManagementConfig{ + PackageVersions: []properties.NugetPackage{ + { + Name: "dep1", + Version: "1.0.0", + IsFixed: true, + }, + { + Name: "dep2", + Version: "1.0.0", + IsFixed: true, + }, + }, + }, + }, + expectedOutputNugetDependencies: []checker.Dependency{ + { + Name: newString("dep1"), + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(true), + }, + }, + }, + { + name: "Some dependencies are fixed", + inputNugetDependencies: []*checker.Dependency{ + { + Name: newString("dep1"), + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(false), + Remediation: &finding.Remediation{ + Text: "remediate", + }, + }, + }, + data: &nugetPostProcessData{ + CpmConfig: properties.CentralPackageManagementConfig{ + PackageVersions: []properties.NugetPackage{ + { + Name: "dep1", + Version: "1.0.0", + IsFixed: true, + }, + { + Name: "dep2", + Version: "1.0.0", + IsFixed: false, + }, + }, + }, + }, + expectedOutputNugetDependencies: []checker.Dependency{ + { + Name: newString("dep1"), + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(false), + }, + }, + }, + { + name: "No dependencies are fixed", + inputNugetDependencies: []*checker.Dependency{ + { + Name: newString("dep1"), + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(false), + Remediation: &finding.Remediation{ + Text: "remediate", + }, + }, + }, + data: &nugetPostProcessData{ + CpmConfig: properties.CentralPackageManagementConfig{ + PackageVersions: []properties.NugetPackage{ + { + Name: "dep1", + Version: "1.0.0", + IsFixed: false, + }, + { + Name: "dep2", + Version: "1.0.0", + IsFixed: false, + }, + }, + }, + }, + expectedOutputNugetDependencies: []checker.Dependency{ + { + Name: newString("dep1"), + Type: checker.DependencyUseTypeNugetCommand, + Pinned: boolAsPointer(false), + }, + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + collectPostProcessNugetCPMDependencies(tt.inputNugetDependencies, tt.data) + for i, dep := range tt.inputNugetDependencies { + if *dep.Pinned != *tt.expectedOutputNugetDependencies[i].Pinned { + t.Errorf("Expected dependency %v, got %v", tt.expectedOutputNugetDependencies[i], dep) + } + } + }) + } +} + func TestPinningDependenciesData_GetDependenciesByType(t *testing.T) { t.Parallel() @@ -2404,6 +2562,113 @@ func TestPinningDependenciesData_GetDependenciesByType(t *testing.T) { } } +func TestAnalyseCentralPackageManagementPinned(t *testing.T) { + t.Parallel() + tests := []struct { + name string + filename string + pinnedDependencies int + unpinnedDependencies int + expectedError bool + IsCPMEnabled bool + }{ + { + name: "Pinned dependencies", + filename: "./testdata/Directory.Pinned.packages.props", + IsCPMEnabled: true, + pinnedDependencies: 1, + unpinnedDependencies: 0, + expectedError: false, + }, + { + name: "Pinned multiple dependencies", + filename: "./testdata/Directory.PinnedMultipleGroups.packages.props", + IsCPMEnabled: true, + pinnedDependencies: 2, + unpinnedDependencies: 0, + expectedError: false, + }, + { + name: "Unpinned CPM false", + filename: "./testdata/Directory.CPMFalse.packages.props", + IsCPMEnabled: false, + pinnedDependencies: 0, + unpinnedDependencies: 0, + expectedError: false, + }, + { + name: "Unpinned CPM undeclared", + filename: "./testdata/Directory.Undeclared.packages.props", + IsCPMEnabled: false, + pinnedDependencies: 0, + unpinnedDependencies: 0, + expectedError: false, + }, + { + name: "Unpinned version undeclared", + filename: "./testdata/Directory.UndeclaredVersions.packages.props", + IsCPMEnabled: true, + pinnedDependencies: 0, + unpinnedDependencies: 1, + expectedError: false, + }, + { + name: "Unpinned version range", + filename: "./testdata/Directory.UnpinnedVersions.packages.props", + IsCPMEnabled: true, + pinnedDependencies: 0, + unpinnedDependencies: 1, + expectedError: false, + }, + { + name: "Unpinned version range in second group", + filename: "./testdata/Directory.UnpinnedMultipleGroups.packages.props", + IsCPMEnabled: true, + pinnedDependencies: 1, + unpinnedDependencies: 1, + expectedError: false, + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var content []byte + var err error + content, err = os.ReadFile(tt.filename) + if err != nil { + t.Fatalf("cannot read file: %v", err) + } + var nugetPostProcessData nugetPostProcessData + dl := scut.TestDetailLogger{} + _, err = processDirectoryPropsFile(tt.filename, content, &nugetPostProcessData, dl) + if tt.expectedError { + if err == nil { + t.Errorf("expected error is nil") + return + } + } + if tt.IsCPMEnabled != nugetPostProcessData.CpmConfig.IsCPMEnabled { + t.Errorf("expected %t cpm enabled. Got %t", tt.IsCPMEnabled, nugetPostProcessData.CpmConfig.IsCPMEnabled) + } + pinned, unpinned := 0, 0 + for _, version := range nugetPostProcessData.CpmConfig.PackageVersions { + if version.IsFixed { + pinned++ + } else { + unpinned++ + } + } + if pinned != tt.pinnedDependencies { + t.Errorf("expected %v pinned dependencies. Got %v", tt.pinnedDependencies, pinned) + } + if unpinned != tt.unpinnedDependencies { + t.Errorf("expected %v unpinned dependencies. Got %v", tt.unpinnedDependencies, unpinned) + } + }) + } +} + func newString(s string) *string { return &s } diff --git a/checks/raw/testdata/Directory.CPMFalse.packages.props b/checks/raw/testdata/Directory.CPMFalse.packages.props new file mode 100644 index 00000000000..5eacc1ca60c --- /dev/null +++ b/checks/raw/testdata/Directory.CPMFalse.packages.props @@ -0,0 +1,8 @@ + + + false + + + + + \ No newline at end of file diff --git a/checks/raw/testdata/Directory.Pinned.packages.props b/checks/raw/testdata/Directory.Pinned.packages.props new file mode 100644 index 00000000000..babc9702bbf --- /dev/null +++ b/checks/raw/testdata/Directory.Pinned.packages.props @@ -0,0 +1,8 @@ + + + true + + + + + \ No newline at end of file diff --git a/checks/raw/testdata/Directory.PinnedMultipleGroups.packages.props b/checks/raw/testdata/Directory.PinnedMultipleGroups.packages.props new file mode 100644 index 00000000000..f5be4009b67 --- /dev/null +++ b/checks/raw/testdata/Directory.PinnedMultipleGroups.packages.props @@ -0,0 +1,11 @@ + + + true + + + + + + + + \ No newline at end of file diff --git a/checks/raw/testdata/Directory.Undeclared.packages.props b/checks/raw/testdata/Directory.Undeclared.packages.props new file mode 100644 index 00000000000..4f0a4f85012 --- /dev/null +++ b/checks/raw/testdata/Directory.Undeclared.packages.props @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/checks/raw/testdata/Directory.UndeclaredVersions.packages.props b/checks/raw/testdata/Directory.UndeclaredVersions.packages.props new file mode 100644 index 00000000000..b4a3be56a33 --- /dev/null +++ b/checks/raw/testdata/Directory.UndeclaredVersions.packages.props @@ -0,0 +1,8 @@ + + + true + + + + + \ No newline at end of file diff --git a/checks/raw/testdata/Directory.UnpinnedMultipleGroups.packages.props b/checks/raw/testdata/Directory.UnpinnedMultipleGroups.packages.props new file mode 100644 index 00000000000..a26a5e26094 --- /dev/null +++ b/checks/raw/testdata/Directory.UnpinnedMultipleGroups.packages.props @@ -0,0 +1,11 @@ + + + true + + + + + + + + \ No newline at end of file diff --git a/checks/raw/testdata/Directory.UnpinnedVersions.packages.props b/checks/raw/testdata/Directory.UnpinnedVersions.packages.props new file mode 100644 index 00000000000..e0bfc4cfae5 --- /dev/null +++ b/checks/raw/testdata/Directory.UnpinnedVersions.packages.props @@ -0,0 +1,8 @@ + + + true + + + + + \ No newline at end of file diff --git a/internal/csproj/csproj.go b/internal/dotnet/csproj/csproj.go similarity index 100% rename from internal/csproj/csproj.go rename to internal/dotnet/csproj/csproj.go diff --git a/internal/dotnet/properties/properties.go b/internal/dotnet/properties/properties.go new file mode 100644 index 00000000000..22bfc12ac15 --- /dev/null +++ b/internal/dotnet/properties/properties.go @@ -0,0 +1,112 @@ +// Copyright 2024 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package properties + +import ( + "encoding/xml" + "errors" + "regexp" +) + +var errInvalidPropsFile = errors.New("error parsing dotnet props file") + +type CPMPropertyGroup struct { + XMLName xml.Name `xml:"PropertyGroup"` + ManagePackageVersionsCentrally bool `xml:"ManagePackageVersionsCentrally"` +} + +type PackageVersionItemGroup struct { + XMLName xml.Name `xml:"ItemGroup"` + PackageVersion []packageVersion `xml:"PackageVersion"` +} + +type packageVersion struct { + XMLName xml.Name `xml:"PackageVersion"` + Version string `xml:"Version,attr"` + Include string `xml:"Include,attr"` +} + +type DirectoryPropsProject struct { + XMLName xml.Name `xml:"Project"` + PropertyGroups []CPMPropertyGroup `xml:"PropertyGroup"` + ItemGroups []PackageVersionItemGroup `xml:"ItemGroup"` +} + +type NugetPackage struct { + Name string + Version string + IsFixed bool +} + +type CentralPackageManagementConfig struct { + PackageVersions []NugetPackage + IsCPMEnabled bool +} + +func GetCentralPackageManagementConfig(path string, content []byte) (CentralPackageManagementConfig, error) { + var project DirectoryPropsProject + + err := xml.Unmarshal(content, &project) + if err != nil { + return CentralPackageManagementConfig{}, errInvalidPropsFile + } + + cpmConfig := CentralPackageManagementConfig{ + IsCPMEnabled: isCentralPackageManagementEnabled(&project), + } + + if cpmConfig.IsCPMEnabled { + cpmConfig.PackageVersions = extractNugetPackages(&project) + } + + return cpmConfig, nil +} + +func isCentralPackageManagementEnabled(project *DirectoryPropsProject) bool { + for _, propertyGroup := range project.PropertyGroups { + if propertyGroup.ManagePackageVersionsCentrally { + return true + } + } + + return false +} + +func extractNugetPackages(project *DirectoryPropsProject) []NugetPackage { + var nugetPackages []NugetPackage + for _, itemGroup := range project.ItemGroups { + for _, packageVersion := range itemGroup.PackageVersion { + nugetPackages = append(nugetPackages, NugetPackage{ + Name: packageVersion.Include, + Version: packageVersion.Version, + IsFixed: isValidFixedVersion(packageVersion.Version), + }) + } + } + return nugetPackages +} + +// isValidFixedVersion checks if the version string is a valid, fixed version. +// more on version numbers here: https://learn.microsoft.com/en-us/nuget/concepts/package-versioning?tabs=semver20sort +// ^: Ensures the match starts at the beginning of the string. +// \[: Matches the opening square bracket [. +// [^\[,]+: Matches one or more characters that are not a comma (,) or a square bracket ([) (to avoid nested brackets). +// \]: Matches the closing square bracket ]. +// $: Ensures the match ends at the end of the string. +func isValidFixedVersion(version string) bool { + pattern := `^\[[^\[,]+\]$` + re := regexp.MustCompile(pattern) + return re.MatchString(version) +} diff --git a/internal/dotnet/properties/properties_test.go b/internal/dotnet/properties/properties_test.go new file mode 100644 index 00000000000..0146e5952c2 --- /dev/null +++ b/internal/dotnet/properties/properties_test.go @@ -0,0 +1,60 @@ +// Copyright 2022 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package properties + +import ( + "testing" +) + +func TestIsValidFixedVersion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + version string + isFixed bool + }{ + {"fixed version", "[10.1.1]", true}, + {"fixed beta version", "[10.1.1-beta]", true}, + {"fixed beta patch", "[10.1.1-beta.1]", true}, + {"fixed version label zzz", "[1.0.1-zzz]", true}, + {"fixed version RC with label", "[1.0.1-rc.10]", true}, + {"fixed version RC with label 2", "[1.0.1-rc.2]", true}, + {"fixed version with label open", "[1.0.1-open]", true}, + {"fixed version alpha", "[1.0.1-alpha2]", true}, + {"fixed version RC with label aaa", "[1.0.1-aaa]", true}, + {"fixed version range", "[1.0]", true}, + {"version as variable", "[$(ComponentDetectionPackageVersion)]", true}, + {"version range with inclusive min", "[1.0,)", false}, + {"version range with inclusive min without brackets", "1.0", false}, + {"version range with exclusive min", "(1.0,)", false}, + {"version range with inclusive max", "(,1.0]", false}, + {"version range with exclusive max", "[,1.0)", false}, + {"Exact range, inclusive", "[1.0,2.0]", false}, + {"Exact range, exclusive", "(1.0,2.0)", false}, + {"Mixed inclusive minimum and exclusive maximum version", "(1.0,2.0)", false}, + {"invalid", "(1.0)", false}, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + isFixed := isValidFixedVersion(tt.version) + if tt.isFixed != isFixed { + t.Errorf("expected %v. Got %v", tt.isFixed, isFixed) + } + }) + } +}