Open Source Security

.NET Supply Chain Audit Patterns

Auditing a .NET supply chain is a different exercise than auditing a JavaScript one, and the patterns that actually find problems are specific to how the ecosystem works.

Nayan Dey
Senior Security Engineer
6 min read

I have spent most of the past year auditing .NET supply chains for customers migrating off legacy platforms, and I keep noticing that the patterns which reliably find problems are different from the ones that work for JavaScript or Python. The generic SCA playbook of "scan manifest, match against CVE feed, generate report" produces a lot of results in .NET, but the results skew heavily toward noise, and the real issues tend to hide in places a generic scan does not look. I want to write down the patterns that have actually worked, partly because they are not well documented and partly because I keep rediscovering them.

The manifest is not the full story

A .NET repository's package manifest lives in one of three places depending on vintage. Modern SDK-style projects express dependencies as <PackageReference> elements in the .csproj file. Older projects use a packages.config file alongside the .csproj. And a repository using central package management, a feature that shipped with NuGet 6.2 in May 2022, has the version numbers in a Directory.Packages.props file at the repository root, with the .csproj files referring to package IDs without versions.

The generic SCA approach of parsing top-level references from the manifest misses a lot in .NET. The real dependency tree includes every transitive package, and the transitive set is usually two or three times the size of the direct set. More importantly, the effective version of a transitive package depends on the solver's choices across the whole graph, and for packages with many consumers the effective version can be different from what any individual manifest suggests. The canonical way to enumerate what actually gets restored is to run dotnet list package --include-transitive --format json against the solution, which calls into the same resolver the build uses. Any audit that works off project files without running the resolver is reporting a subset of the truth.

Framework version drift

The target framework moniker in a .NET project is a security signal people often overlook. A project targeting net462 is pulling from the .NET Framework 4.6.2 ecosystem, which is still supported through the Windows servicing model but is not receiving the same security attention as net8.0. A project targeting netstandard2.0 is portable across frameworks, which makes the effective dependency set different depending on where the project gets consumed. And a project targeting a TFM that is out of Microsoft's support window is inheriting unpatched framework vulnerabilities even if its package dependencies are all current.

The pattern that works is to enumerate every TFM used across a solution and check each one against Microsoft's .NET support lifecycle. .NET 6 went out of support on November 12, 2024. .NET 7 went out of support on May 14, 2024. Any project still targeting those TFMs after those dates is receiving no framework security updates. I build a spreadsheet for every customer audit, one row per project, with columns for TFM, support status, and days-since-EOL.

The Microsoft.Extensions ecosystem

A substantial fraction of the transitive dependency weight in a modern .NET project comes from the Microsoft.Extensions family: Microsoft.Extensions.DependencyInjection, Microsoft.Extensions.Logging, Microsoft.Extensions.Configuration, and so on. These packages are versioned in lockstep with .NET itself, and their versions pulled in transitively often differ from the version the project's primary packages expect. This is usually fine because Microsoft maintains compatibility, but it becomes a security audit concern when you are tracking specific CVEs.

CVE-2024-30045, the .NET Core denial of service vulnerability disclosed in May 2024, affected Microsoft.AspNetCore.Server.Kestrel.Core in versions before 6.0.31 and 8.0.5. An audit looking only at direct dependencies might miss that a project's transitive pull of Kestrel was a vulnerable version, because the project's direct dependency was some higher-level framework package. The pattern is to resolve transitives, check the effective version of every Microsoft.Extensions and Microsoft.AspNetCore package, and match against the specific CVE lists for those packages.

Private feed drift

Every enterprise .NET shop has internal NuGet feeds. A common audit finding is that the internal feed carries packages with names that shadow public packages, and the resolver's source precedence determines which gets picked. The packageSourceMapping feature, introduced in NuGet 6.0 in November 2021, lets you specify which feed is authoritative for which package name prefix, and using it is the defence against dependency confusion attacks in .NET.

The audit pattern is to compare the set of package IDs on every internal feed against the set on nuget.org, flag collisions, and check whether the project has packageSourceMapping configured. Collisions without mapping are findings. I have never audited a .NET shop that did not have at least one.

Unofficial mirrors and proxies

Related to the private feed pattern but distinct, a lot of .NET shops have an Artifactory, Azure DevOps, or ProGet proxy that caches nuget.org. The proxy acts as a cache for the public feed, which is fine in principle, but the trust boundary depends on how the proxy is configured. If the proxy allows uploads of arbitrary packages, it becomes a place where unsigned or tampered packages can be injected. If the proxy strips or fails to validate signatures on the way through, the repository-signature-based trust chain is broken.

The audit pattern is to verify that every proxy in use has read-only upstream caching from nuget.org, that signatures are preserved end-to-end, and that write access is restricted to a specific set of accounts and build service principals. The worst case I have seen was a proxy where any authenticated domain user could overwrite cached packages with their own content. Nobody had abused it, but the blast radius was enormous.

The obsolete-but-pinned pattern

A pattern I see more in .NET than in other ecosystems is a project pinned to a specific old version of a package, sometimes four or five years old, because of a regression or API change in a newer version that nobody has time to work around. Newtonsoft.Json 9.x is a common example; so are early versions of AutoMapper and Serilog.

These pins are security concerns when the pinned version has known CVEs that were fixed in a later version the project refuses to adopt. Newtonsoft.Json 11.x and earlier had a deserialization concern fixed in 13.0.1 in March 2021 (GHSA-5crp-9r3c-p9vr). A project pinned to Newtonsoft.Json 10.0.3 is vulnerable, and remains so until someone either adopts 13.x or migrates to System.Text.Json.

How Safeguard Helps

Safeguard resolves .NET transitive dependency graphs using the actual NuGet resolver, so the package inventory reflects what restores rather than what the manifest declares. It tracks target framework support lifecycle against the Microsoft roadmap, flags projects on EOL TFMs, and correlates effective transitive package versions against CVE feeds with ecosystem-specific mappings. For the private feed and proxy patterns the platform inventories internal feeds alongside the public feed and surfaces ID collisions. During an audit engagement the platform replaces several of the spreadsheets the manual patterns above depend on.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.