From abbfe84d63059ed036551f5fa55e1185871ede60 Mon Sep 17 00:00:00 2001 From: balteravishay Date: Sun, 6 Oct 2024 18:24:02 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Support=20Central=20Package=20Management=20?= =?UTF-8?q?Co-authored-by:=20Liam=20Moat=20=20Co-aut?= =?UTF-8?q?hored-by:=20Ioana=20A=20=20Co?= =?UTF-8?q?-authored-by:=20M=C3=A9lanie=20Guittet=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: balteravishay --- checks/raw/pinned_dependencies.go | 142 ++++++++++++---- checks/raw/pinned_dependencies_test.go | 154 ++++++++++++++++-- .../Directory.CPMFalse.packages.props | 8 + .../testdata/Directory.Pinned.packages.props | 8 + ...ectory.PinnedMultipleGroups.packages.props | 11 ++ .../Directory.Undeclared.packages.props | 7 + ...irectory.UndeclaredVersions.packages.props | 8 + ...tory.UnpinnedMultipleGroups.packages.props | 11 ++ .../Directory.UnpinnedVersions.packages.props | 8 + internal/{ => dotnet}/csproj/csproj.go | 0 internal/dotnet/properties/properties.go | 116 +++++++++++++ internal/dotnet/properties/properties_test.go | 46 ++++++ 12 files changed, 479 insertions(+), 40 deletions(-) create mode 100644 checks/raw/testdata/Directory.CPMFalse.packages.props create mode 100644 checks/raw/testdata/Directory.Pinned.packages.props create mode 100644 checks/raw/testdata/Directory.PinnedMultipleGroups.packages.props create mode 100644 checks/raw/testdata/Directory.Undeclared.packages.props create mode 100644 checks/raw/testdata/Directory.UndeclaredVersions.packages.props create mode 100644 checks/raw/testdata/Directory.UnpinnedMultipleGroups.packages.props create mode 100644 checks/raw/testdata/Directory.UnpinnedVersions.packages.props rename internal/{ => dotnet}/csproj/csproj.go (100%) create mode 100644 internal/dotnet/properties/properties.go create mode 100644 internal/dotnet/properties/properties_test.go diff --git a/checks/raw/pinned_dependencies.go b/checks/raw/pinned_dependencies.go index 0e6896d8fcb..527dbce1cca 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,87 @@ 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 { + if unpinnedDependencies := getUnpinnedNugetDependencies(pinningDependenciesData); len(unpinnedDependencies) > 0 { + 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 + + numFixedVersions, unfixedVersions := countFixedVersions(packageVersions) + // if all dependencies are fixed to specific versions, pin all dependencies + if numFixedVersions == len(packageVersions) { + pinAllNugetDependencies(unpinnedNugetDependencies) + } else { + // 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 +177,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 +207,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 +259,17 @@ func countUnlocked(csprojFiles []dotnetCsprojLockedData) (int, string) { return len(unlockedPaths), strings.Join(unlockedPaths, ", ") } +func countFixedVersions(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..81430c8f4d8 100644 --- a/checks/raw/pinned_dependencies_test.go +++ b/checks/raw/pinned_dependencies_test.go @@ -2170,22 +2170,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 +2197,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 +2206,7 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { }, }, }, - outcomeDependencies: []*checker.Dependency{ + outcomeDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(false), @@ -2220,7 +2220,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 +2229,7 @@ func TestCollectInsecureNugetCsproj(t *testing.T) { }, }, }, - outcomeDependencies: []*checker.Dependency{ + outcomeDependencies: []checker.Dependency{ { Type: checker.DependencyUseTypeNugetCommand, Pinned: boolAsPointer(true), @@ -2241,14 +2241,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 +2256,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 +2290,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()) @@ -2404,6 +2429,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..65b2f3a7ddf --- /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..211eec0874e --- /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..2f64e18492d --- /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..44223ed786a --- /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..87a04c429ee --- /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..8098eff321b --- /dev/null +++ b/internal/dotnet/properties/properties.go @@ -0,0 +1,116 @@ +// 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 +// ^ asserts the start of the string +// (\d+)\.(\d+)\.(\d+) matches major.minor.patch version (e.g., 1.0.1) +// (-[a-zA-Z]+(\.\d+)?|[a-zA-Z]+\d+)? matches optional pre-release tag +// +// -[a-zA-Z]+(\.\d+)? handles pre-release versions with dots (e.g., -beta.12, -rc.10) +// -[a-zA-Z]+\d+ handles versions like -alpha2 and -alpha10 +// +// \[\d+\.\d+\] or \[\d+\.\d+\.\d+\] matches cases like [1.0] and [1.0.1] +// \$\(ComponentDetectionPackageVersion\) matches special case like $(ComponentDetectionPackageVersion). +func isValidFixedVersion(version string) bool { + pattern := `^(\d+)\.(\d+)\.(\d+)(-[a-zA-Z]+(\.\d+)?|-[a-zA-Z]+\d*)?$|^\[\d+\.\d+\]$|^\[\d+\.\d+\.\d+\]$|^\$\(.+\)$` + 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..a6b790d1d0e --- /dev/null +++ b/internal/dotnet/properties/properties_test.go @@ -0,0 +1,46 @@ +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) + } + }) + } +} From d83920084262beda74683f1920b5fe630ab5f8ce Mon Sep 17 00:00:00 2001 From: balteravishay Date: Mon, 7 Oct 2024 08:31:00 +0000 Subject: [PATCH 2/2] license Signed-off-by: balteravishay --- internal/dotnet/properties/properties_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/dotnet/properties/properties_test.go b/internal/dotnet/properties/properties_test.go index a6b790d1d0e..ec5dc80ca18 100644 --- a/internal/dotnet/properties/properties_test.go +++ b/internal/dotnet/properties/properties_test.go @@ -1,3 +1,17 @@ +// 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 (