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)
+ }
+ })
+ }
+}