From e5fd03d2f6052d4df78da79df6f19b305e187c36 Mon Sep 17 00:00:00 2001 From: Alan Pope Date: Tue, 26 Aug 2025 12:01:11 +0100 Subject: [PATCH] First pass at cataloging .csproj files Signed-off-by: Alan Pope --- internal/task/package_tasks.go | 1 + syft/pkg/cataloger/dotnet/cataloger.go | 6 + .../cataloger/dotnet/cataloger_csproj_test.go | 211 ++++++++++ syft/pkg/cataloger/dotnet/parse_csproj.go | 160 ++++++++ .../pkg/cataloger/dotnet/parse_csproj_test.go | 384 ++++++++++++++++++ .../steeltoe-sample/WeatherForecast.csproj | 29 ++ 6 files changed, 791 insertions(+) create mode 100644 syft/pkg/cataloger/dotnet/cataloger_csproj_test.go create mode 100644 syft/pkg/cataloger/dotnet/parse_csproj.go create mode 100644 syft/pkg/cataloger/dotnet/parse_csproj_test.go create mode 100644 syft/pkg/cataloger/dotnet/test-fixtures/steeltoe-sample/WeatherForecast.csproj diff --git a/internal/task/package_tasks.go b/internal/task/package_tasks.go index 6ba932724..f9e674325 100644 --- a/internal/task/package_tasks.go +++ b/internal/task/package_tasks.go @@ -126,6 +126,7 @@ func DefaultPackageTaskFactories() Factories { pkgcataloging.InstalledTag, pkgcataloging.ImageTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "dotnet", "c#", ), newSimplePackageTaskFactory(dotnet.NewDotnetPackagesLockCataloger, pkgcataloging.DeclaredTag, pkgcataloging.ImageTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "dotnet", "c#"), + newSimplePackageTaskFactory(dotnet.NewDotnetCsprojCataloger, pkgcataloging.DeclaredTag, pkgcataloging.DirectoryTag, pkgcataloging.LanguageTag, "dotnet", "c#"), newSimplePackageTaskFactory(python.NewInstalledPackageCataloger, pkgcataloging.DirectoryTag, pkgcataloging.InstalledTag, pkgcataloging.ImageTag, pkgcataloging.LanguageTag, "python"), newPackageTaskFactory( func(cfg CatalogingFactoryConfig) pkg.Cataloger { diff --git a/syft/pkg/cataloger/dotnet/cataloger.go b/syft/pkg/cataloger/dotnet/cataloger.go index 384469db3..3337d5434 100644 --- a/syft/pkg/cataloger/dotnet/cataloger.go +++ b/syft/pkg/cataloger/dotnet/cataloger.go @@ -29,3 +29,9 @@ func NewDotnetPackagesLockCataloger() pkg.Cataloger { return generic.NewCataloger("dotnet-packages-lock-cataloger"). WithParserByGlobs(parseDotnetPackagesLock, "**/packages.lock.json") } + +// NewDotnetCsprojCataloger returns a cataloger based on .csproj files. +func NewDotnetCsprojCataloger() pkg.Cataloger { + return generic.NewCataloger("dotnet-csproj-cataloger"). + WithParserByGlobs(parseDotnetCsproj, "**/*.csproj") +} diff --git a/syft/pkg/cataloger/dotnet/cataloger_csproj_test.go b/syft/pkg/cataloger/dotnet/cataloger_csproj_test.go new file mode 100644 index 000000000..b653f2d1f --- /dev/null +++ b/syft/pkg/cataloger/dotnet/cataloger_csproj_test.go @@ -0,0 +1,211 @@ +package dotnet + +import ( + "testing" + + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/internal/pkgtest" +) + +func TestDotnetCsprojCataloger(t *testing.T) { + fixture := "test-fixtures/steeltoe-sample/WeatherForecast.csproj" + fixtureLocationSet := file.NewLocationSet(file.NewLocation(fixture)) + + expected := []pkg.Package{ + { + Name: "Steeltoe.Discovery.Eureka", + Version: "3.2.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Discovery.Eureka@3.2.0", + Locations: fixtureLocationSet, + Metadata: pkg.DotnetDepsEntry{ + Name: "Steeltoe.Discovery.Eureka", + Version: "3.2.0", + Path: "test-fixtures/steeltoe-sample", + Sha512: "", + HashPath: "", + }, + }, + { + Name: "Steeltoe.Extensions.Configuration.CloudFoundry", + Version: "3.2.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Extensions.Configuration.CloudFoundry@3.2.0", + Locations: fixtureLocationSet, + Metadata: pkg.DotnetDepsEntry{ + Name: "Steeltoe.Extensions.Configuration.CloudFoundry", + Version: "3.2.0", + Path: "test-fixtures/steeltoe-sample", + Sha512: "", + HashPath: "", + }, + }, + { + Name: "Steeltoe.Management.Endpoint", + Version: "3.2.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Management.Endpoint@3.2.0", + Locations: fixtureLocationSet, + Metadata: pkg.DotnetDepsEntry{ + Name: "Steeltoe.Management.Endpoint", + Version: "3.2.0", + Path: "test-fixtures/steeltoe-sample", + Sha512: "", + HashPath: "", + }, + }, + { + Name: "Microsoft.AspNetCore.OpenApi", + Version: "8.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Microsoft.AspNetCore.OpenApi@8.0.0", + Locations: fixtureLocationSet, + Metadata: pkg.DotnetDepsEntry{ + Name: "Microsoft.AspNetCore.OpenApi", + Version: "8.0.0", + Path: "test-fixtures/steeltoe-sample", + Sha512: "", + HashPath: "", + }, + }, + { + Name: "Swashbuckle.AspNetCore", + Version: "6.5.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Swashbuckle.AspNetCore@6.5.0", + Locations: fixtureLocationSet, + Metadata: pkg.DotnetDepsEntry{ + Name: "Swashbuckle.AspNetCore", + Version: "6.5.0", + Path: "test-fixtures/steeltoe-sample", + Sha512: "", + HashPath: "", + }, + }, + { + Name: "Serilog.AspNetCore", + Version: "8.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Serilog.AspNetCore@8.0.0", + Locations: fixtureLocationSet, + Metadata: pkg.DotnetDepsEntry{ + Name: "Serilog.AspNetCore", + Version: "8.0.0", + Path: "test-fixtures/steeltoe-sample", + Sha512: "", + HashPath: "", + }, + }, + { + Name: "Serilog.Sinks.Console", + Version: "5.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Serilog.Sinks.Console@5.0.0", + Locations: fixtureLocationSet, + Metadata: pkg.DotnetDepsEntry{ + Name: "Serilog.Sinks.Console", + Version: "5.0.0", + Path: "test-fixtures/steeltoe-sample", + Sha512: "", + HashPath: "", + }, + }, + } + + pkgtest.TestCataloger(t, fixture, NewDotnetCsprojCataloger(), expected, nil) +} + +func TestDotnetCsprojCataloger_GlopsMatch(t *testing.T) { + expectedPackages := []pkg.Package{ + { + Name: "Serilog", + Version: "2.10.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Serilog@2.10.0", + }, + { + Name: "Serilog.Sinks.Console", + Version: "4.0.1", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Serilog.Sinks.Console@4.0.1", + }, + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + { + Name: "Humanizer", + Version: "2.14.1", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Humanizer@2.14.1", + }, + { + Name: "Microsoft.Web.LibraryManager.Build", + Version: "2.1.175", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Microsoft.Web.LibraryManager.Build@2.1.175", + }, + { + Name: "Steeltoe.Discovery.Eureka", + Version: "3.2.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Discovery.Eureka@3.2.0", + }, + { + Name: "Steeltoe.Extensions.Configuration.CloudFoundry", + Version: "3.2.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Extensions.Configuration.CloudFoundry@3.2.0", + }, + { + Name: "Steeltoe.Management.Endpoint", + Version: "3.2.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Steeltoe.Management.Endpoint@3.2.0", + }, + { + Name: "Microsoft.AspNetCore.OpenApi", + Version: "8.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Microsoft.AspNetCore.OpenApi@8.0.0", + }, + { + Name: "Swashbuckle.AspNetCore", + Version: "6.5.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Swashbuckle.AspNetCore@6.5.0", + }, + { + Name: "Serilog.AspNetCore", + Version: "8.0.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Serilog.AspNetCore@8.0.0", + }, + } + + pkgtest.NewCatalogTester(). + FromDirectory(t, "test-fixtures"). + Expects(expectedPackages, nil). + TestCataloger(t, NewDotnetCsprojCataloger()) +} diff --git a/syft/pkg/cataloger/dotnet/parse_csproj.go b/syft/pkg/cataloger/dotnet/parse_csproj.go new file mode 100644 index 000000000..1e277c017 --- /dev/null +++ b/syft/pkg/cataloger/dotnet/parse_csproj.go @@ -0,0 +1,160 @@ +package dotnet + +import ( + "context" + "encoding/xml" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/anchore/packageurl-go" + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +// 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"` +} + +// csprojItemGroup represents an ItemGroup element containing references +type csprojItemGroup struct { + PackageReferences []csprojPackageReference `xml:"PackageReference"` + ProjectReferences []csprojProjectReference `xml:"ProjectReference"` +} + +// csprojPackageReference represents a PackageReference element +type csprojPackageReference struct { + Include string `xml:"Include,attr"` + Version string `xml:"Version,attr"` + PrivateAssets string `xml:"PrivateAssets,attr"` + IncludeAssets string `xml:"IncludeAssets,attr"` + Condition string `xml:"Condition,attr"` +} + +// csprojProjectReference represents a ProjectReference element +type csprojProjectReference struct { + Include string `xml:"Include,attr"` + Condition string `xml:"Condition,attr"` +} + +func parseDotnetCsproj(_ context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { + contents, err := io.ReadAll(reader) + if err != nil { + return nil, nil, fmt.Errorf("unable to read .csproj file: %w", err) + } + + var project csprojProject + if err := xml.Unmarshal(contents, &project); err != nil { + return nil, nil, fmt.Errorf("unable to parse .csproj XML: %w", err) + } + + var pkgs []pkg.Package + var relationships []artifact.Relationship + + // Process PackageReference elements + for _, itemGroup := range project.ItemGroups { + for _, pkgRef := range itemGroup.PackageReferences { + // Skip packages that are build-time only or analyzers + if shouldSkipPackageReference(pkgRef) { + continue + } + + p := buildPackageFromReference(pkgRef, reader.Location) + if p != nil { + pkgs = append(pkgs, *p) + } + } + + // Process ProjectReference elements (if we want to include them as relationships) + for _, projRef := range itemGroup.ProjectReferences { + // ProjectReferences represent internal project dependencies + // We could create relationships here, but for now we skip them + // since they represent source-to-source dependencies within the same solution + _ = projRef + } + } + + return pkgs, relationships, nil +} + +// shouldSkipPackageReference determines if a package reference should be skipped +func shouldSkipPackageReference(ref csprojPackageReference) bool { + // Skip packages that are private assets only (build-time dependencies) + if ref.PrivateAssets == "all" || ref.PrivateAssets == "All" { + return true + } + + // Skip conditional references that are likely build/development only + condition := strings.ToLower(ref.Condition) + if strings.Contains(condition, "debug") && !strings.Contains(condition, "release") { + return true + } + + // Skip packages that are commonly build-time only + lowerName := strings.ToLower(ref.Include) + buildTimePackages := []string{ + "microsoft.net.test.sdk", + "stylecop.analyzers", + "microsoft.codeanalysis", + "coverlet.collector", + "xunit.runner.visualstudio", + } + + for _, buildPkg := range buildTimePackages { + if strings.Contains(lowerName, buildPkg) { + return true + } + } + + return false +} + +// buildPackageFromReference creates a Package from a PackageReference element +func buildPackageFromReference(ref csprojPackageReference, location file.Location) *pkg.Package { + name := strings.TrimSpace(ref.Include) + if name == "" { + return nil + } + + version := strings.TrimSpace(ref.Version) + // If version is empty, this might be a framework reference or implicit version + // For now, we'll skip packages without explicit versions since we can't determine them + // from the .csproj alone (would need props/targets files or lock files) + if version == "" { + return nil + } + + // Generate PURL following the established pattern for .NET packages + purl := packageurl.NewPackageURL( + packageurl.TypeNuget, + "", + name, + version, + nil, + "", + ) + + p := &pkg.Package{ + Name: name, + Version: version, + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: purl.ToString(), + Locations: file.NewLocationSet(location), + Metadata: pkg.DotnetDepsEntry{ + Name: name, + Version: version, + Path: filepath.Dir(location.RealPath), + Sha512: "", + HashPath: "", + }, + } + + return p +} diff --git a/syft/pkg/cataloger/dotnet/parse_csproj_test.go b/syft/pkg/cataloger/dotnet/parse_csproj_test.go new file mode 100644 index 000000000..52095882f --- /dev/null +++ b/syft/pkg/cataloger/dotnet/parse_csproj_test.go @@ -0,0 +1,384 @@ +package dotnet + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +func TestParseDotnetCsproj(t *testing.T) { + tests := []struct { + name string + input string + expected []pkg.Package + expectedError bool + }{ + { + name: "basic PackageReference parsing", + input: ` + + net8.0 + + + + + +`, + expected: []pkg.Package{ + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + { + Name: "Serilog", + Version: "2.10.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Serilog@2.10.0", + }, + }, + }, + { + name: "skip private assets", + input: ` + + + + + +`, + expected: []pkg.Package{ + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + }, + }, + { + name: "skip conditional debug-only references", + input: ` + + + + +`, + expected: []pkg.Package{ + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + }, + }, + { + name: "skip build-time packages", + input: ` + + + + + + +`, + expected: []pkg.Package{ + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + }, + }, + { + name: "skip packages without version", + input: ` + + + + +`, + expected: []pkg.Package{ + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + }, + }, + { + name: "multiple ItemGroup elements", + input: ` + + + + + + +`, + expected: []pkg.Package{ + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + { + Name: "Serilog", + Version: "2.10.0", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Serilog@2.10.0", + }, + }, + }, + { + name: "empty project", + input: ` + + net8.0 + +`, + expected: []pkg.Package{}, + }, + { + name: "ProjectReference ignored", + input: ` + + + + +`, + expected: []pkg.Package{ + { + Name: "Newtonsoft.Json", + Version: "13.0.3", + Language: pkg.Dotnet, + Type: pkg.DotnetPkg, + PURL: "pkg:nuget/Newtonsoft.Json@13.0.3", + }, + }, + }, + { + name: "malformed XML", + input: ` + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file