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