diff --git a/syft/pkg/cataloger/dotnet/parse_csproj.go b/syft/pkg/cataloger/dotnet/parse_csproj.go index fdc83592b..2e2b9a12b 100644 --- a/syft/pkg/cataloger/dotnet/parse_csproj.go +++ b/syft/pkg/cataloger/dotnet/parse_csproj.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "path/filepath" + "regexp" "strings" "github.com/anchore/packageurl-go" @@ -17,9 +18,21 @@ import ( // csprojProject represents the root element of a .csproj file type csprojProject struct { - XMLName xml.Name `xml:"Project"` - Sdk string `xml:"Sdk,attr"` - ItemGroups []csprojItemGroup `xml:"ItemGroup"` + XMLName xml.Name `xml:"Project"` + Sdk string `xml:"Sdk,attr"` + PropertyGroups []csprojPropertyGroup `xml:"PropertyGroup"` + ItemGroups []csprojItemGroup `xml:"ItemGroup"` +} + +// csprojPropertyGroup represents a PropertyGroup element containing MSBuild properties +type csprojPropertyGroup struct { + Properties []csprojProperty `xml:",any"` +} + +// csprojProperty represents any property within a PropertyGroup +type csprojProperty struct { + XMLName xml.Name + Value string `xml:",chardata"` } // csprojItemGroup represents an ItemGroup element containing references @@ -54,6 +67,9 @@ func parseDotnetCsproj(_ context.Context, _ file.Resolver, _ *generic.Environmen return nil, nil, fmt.Errorf("unable to parse .csproj XML: %w", err) } + // Build property map from PropertyGroups + properties := buildPropertyMap(project.PropertyGroups) + var pkgs []pkg.Package var relationships []artifact.Relationship @@ -65,6 +81,10 @@ func parseDotnetCsproj(_ context.Context, _ file.Resolver, _ *generic.Environmen continue } + // Resolve any MSBuild property variables in the version + resolvedVersion := resolveProperties(pkgRef.Version, properties) + pkgRef.Version = resolvedVersion + p := buildPackageFromReference(pkgRef, reader.Location) if p != nil { pkgs = append(pkgs, *p) @@ -99,15 +119,15 @@ func shouldSkipPackageReference(ref csprojPackageReference) bool { // Skip packages that are commonly build-time only lowerName := strings.ToLower(ref.Include) buildTimePackages := map[string]bool{ - "microsoft.net.test.sdk": true, - "stylecop.analyzers": true, - "microsoft.codeanalysis": true, - "coverlet.collector": true, - "xunit.runner.visualstudio": true, - "nunit": true, - "nunit3testadapter": true, - "mstest.testadapter": true, - "mstest.testframework": true, + "microsoft.net.test.sdk": true, + "stylecop.analyzers": true, + "microsoft.codeanalysis": true, + "coverlet.collector": true, + "xunit.runner.visualstudio": true, + "nunit": true, + "nunit3testadapter": true, + "mstest.testadapter": true, + "mstest.testframework": true, } for buildPkg := range buildTimePackages { @@ -119,6 +139,48 @@ func shouldSkipPackageReference(ref csprojPackageReference) bool { return false } +// buildPropertyMap creates a map of MSBuild properties from PropertyGroups +func buildPropertyMap(propertyGroups []csprojPropertyGroup) map[string]string { + properties := make(map[string]string) + + for _, group := range propertyGroups { + for _, prop := range group.Properties { + propertyName := prop.XMLName.Local + propertyValue := strings.TrimSpace(prop.Value) + if propertyName != "" && propertyValue != "" { + properties[propertyName] = propertyValue + } + } + } + + return properties +} + +// resolveProperties resolves MSBuild property variables like $(PropertyName) in a string +func resolveProperties(input string, properties map[string]string) string { + if input == "" { + return input + } + + // Pattern matches $(PropertyName) + propertyPattern := `\$\(([^)]+)\)` + re := regexp.MustCompile(propertyPattern) + + result := re.ReplaceAllStringFunc(input, func(match string) string { + // Extract property name from $(PropertyName) + propertyName := match[2 : len(match)-1] // Remove $( and ) + + if value, exists := properties[propertyName]; exists { + return value + } + + // Return original if property not found (preserve for debugging) + return match + }) + + return result +} + // buildPackageFromReference creates a Package from a PackageReference element func buildPackageFromReference(ref csprojPackageReference, location file.Location) *pkg.Package { name := strings.TrimSpace(ref.Include) @@ -134,6 +196,11 @@ func buildPackageFromReference(ref csprojPackageReference, location file.Locatio return nil } + // Skip packages with unresolved MSBuild properties (contains $(...)) + if strings.Contains(version, "$(") { + return nil + } + // Generate PURL following the established pattern for .NET packages purl := packageurl.NewPackageURL( packageurl.TypeNuget, diff --git a/syft/pkg/cataloger/dotnet/parse_csproj_test.go b/syft/pkg/cataloger/dotnet/parse_csproj_test.go index 1b65fb166..746f78073 100644 --- a/syft/pkg/cataloger/dotnet/parse_csproj_test.go +++ b/syft/pkg/cataloger/dotnet/parse_csproj_test.go @@ -2,6 +2,7 @@ package dotnet import ( "context" + "encoding/xml" "io" "strings" "testing" @@ -205,6 +206,89 @@ func TestParseDotnetCsproj(t *testing.T) { }, }, }, + { + name: "property variable resolution", + input: ` + + 3.2.0 + 1.0.0 + + + + + + +`, + expected: []pkg.Package{ + { + Name: "Steeltoe.Common", + Version: "3.2.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Common@3.2.0", + }, + { + Name: "Test.Package", + Version: "1.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Test.Package@1.0.0", + }, + { + Name: "Regular.Package", + Version: "2.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Regular.Package@2.0.0", + }, + }, + }, + { + name: "wildcard versions preserved", + input: ` + + 3.2.* + + + + + +`, + expected: []pkg.Package{ + { + Name: "Steeltoe.Common", + Version: "3.2.*", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Common@3.2.%2A", + }, + { + Name: "Swashbuckle.AspNetCore", + Version: "6.2.*", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Swashbuckle.AspNetCore@6.2.%2A", + }, + }, + }, + { + name: "unresolved property preserved", + input: ` + + + + +`, + expected: []pkg.Package{ + { + Name: "Regular.Package", + Version: "2.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Regular.Package@2.0.0", + }, + }, + }, { name: "malformed XML", input: ` + + + net6.0 + enable + enable + InProcess + + + + 3.2.* + + + + + + + + + \ No newline at end of file