
How to Find NuGet Dependency Vulnerabilities Before They Find You in .NET
Author - Abdul Rahman (Bhai)
NuGet
6 Articles
Table of Contents
What we gonna do?
Your .NET project has five PackageReference entries in its .csproj. How many NuGet packages are actually running in your app? The answer is rarely five — it's often fifty or more. Every package you reference brings its own dependencies, and those bring theirs. This invisible supply chain is called your transitive dependency tree, and most teams cannot tell you what's in it.
NuGet dependency auditing is the practice of systematically inspecting your entire package graph — direct and transitive — to identify vulnerabilities, deprecated packages, and outdated versions before they cause incidents. Since .NET 8, the SDK ships with NuGet Audit built in, making this something every .NET team can do for free, right now.
In this article, you will learn how to inventory your full dependency tree, find packages with known CVEs (Common Vulnerabilities and Exposures), spot deprecated and outdated packages, and configure your build pipeline to catch these issues automatically.
Why we gonna do?
You Are Shipping Code You Did Not Write — And Probably Did Not Review
Consider a typical .NET Web API project. You add five packages: FluentValidation, Dapper, Serilog, AutoMapper, and Polly. Running dotnet list package --include-transitive reveals the real picture — often 60 to 100+ packages loaded at runtime, chosen by third parties, not your team.
Here is the critical distinction: with direct dependencies, your team chose the package and reviewed its implications. With transitive dependencies, a third-party maintainer made that choice for you — including which version to pin, and whether to update when vulnerabilities are disclosed. That inherited risk accumulates silently over months.
The consequences are concrete. A transitive dependency with a high-severity CVE can expose your application to remote code execution, data exfiltration, or denial-of-service — even if you never wrote a single line that directly uses the vulnerable package. When a security scan flags it, the burden of the fix falls on you.
The Three Dimensions of Dependency Risk
Not all dependency problems look like active exploits. There are three categories to monitor:
- Vulnerabilities (CVEs): Known security flaws catalogued in the National Vulnerability Database (NVD). GitHub maintains a Security Advisory Database (GHSA) — populated from CVEs and directly from package maintainers — which NuGet's advisory feed consumes. NuGet Audit surfaces them at build time.
- Deprecation: A package maintainer has formally declared the package end-of-life or superseded. Continuing to use it means no further security patches — even if no CVE exists today.
- Staleness: A package has not been updated in years, and newer versions of its dependencies have shipped fixes your version will never receive. Not flagged as deprecated, but equally problematic.
The tools built into the .NET SDK let you detect all three. Here's how.
How we gonna do?
Step 1: Inventory Your Full Dependency Tree
Run this command from your project or solution root to see every package — direct and transitive — that your application loads at runtime:
# List only direct dependencies
dotnet list package
# Include transitive dependencies (the full picture)
dotnet list package --include-transitive
# Output as JSON for tooling integration
dotnet list package --include-transitive --format json
The output separates Top-level Packages (what your team added) from Transitive Packages (what they pulled in). Pay attention to the Resolved column — NuGet's version resolution can differ from what you requested, particularly when multiple packages require different versions of the same dependency.
Project 'OrderApi' has the following package references
[net10.0]:
Top-level Packages:
Requested Resolved
> Dapper 2.1.35 2.1.35
> FluentValidation 11.10.0 11.10.0
> Polly 8.5.2 8.5.2
Transitive Packages:
Resolved
> Microsoft.Bcl.AsyncInterfaces 9.0.4
> System.Threading.Tasks.Extensions 4.5.4
... (50+ more entries)
Step 2: Find Vulnerable Packages With NuGet Audit
As of .NET 8, NuGet Audit runs automatically during dotnet restore and emits warnings for packages with known vulnerabilities. You can also query it explicitly:
# Report vulnerable packages (direct only, by default)
dotnet list package --vulnerable
# Include transitive packages in the vulnerability scan
dotnet list package --vulnerable --include-transitive
The output identifies each vulnerable package, its severity level, and a link to the advisory:
Project 'OrderApi' has the following vulnerable packages
[net10.0]:
Top-level Packages:
Requested Resolved Severity Advisory URL
> SomeOldPackage 1.2.0 1.2.0 High https://github.com/advisories/GHSA-xxxx-yyyy-zzzz
Transitive Packages:
Resolved Severity Advisory URL
> AnotherTransitivePkg 3.1.0 High https://github.com/advisories/GHSA-aaaa-bbbb-cccc
Severity levels follow the standard Low / Moderate / High / Critical classification from the NVD's CVSS scores. A Critical vulnerability in a transitive package demands the same urgency as one in a direct dependency — your runtime doesn't care about the distinction.
Step 3: Configure NuGet Audit in Your Build
By default, NuGet Audit emits warnings. In a security-conscious CI pipeline, you want vulnerabilities to fail the build. The correct place for solution-wide audit configuration is Directory.Build.props at the solution root — this file applies MSBuild properties to every project automatically:
<!-- Directory.Build.props — place at your solution root, applies to all projects -->
<Project>
<PropertyGroup>
<!-- Audit both direct and transitive packages (default is 'direct' only) -->
<NuGetAuditMode>all</NuGetAuditMode>
<!-- Minimum severity to warn about: low, moderate, high, critical -->
<NuGetAuditLevel>high</NuGetAuditLevel>
<!-- Treat audit warnings as errors so the build actually fails -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
Two properties are doing the real work here. NuGetAuditMode defaults to direct, meaning the automatic restore only flags vulnerabilities in your directly referenced packages — transitive packages are silently skipped. Setting it to all closes that gap. TreatWarningsAsErrors is what actually breaks the build — without it, audit warnings are just warnings that get scrolled past in CI output.
You can override these solution-wide defaults per-project in the .csproj file, which is useful when different projects in a solution have different risk tolerances:
<PropertyGroup>
<!-- Enable or disable audit for this specific project -->
<NuGetAudit>true</NuGetAudit>
<!-- Minimum severity that triggers a warning: low, moderate, high, critical -->
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<!-- Audit direct packages only, or all (includes transitive) -->
<NuGetAuditMode>all</NuGetAuditMode>
</PropertyGroup>
With NuGetAuditLevel set to moderate, any restore that resolves a package with a moderate-or-higher CVE will emit a build warning (or error, if you set TreatWarningsAsErrors). This gives you a go/no-go gate in your CI without needing a separate third-party scanner.
Step 4: Detect Deprecated Packages
Deprecated packages are those formally marked end-of-life by their maintainers on NuGet.org. They will receive no further security patches. Find them with:
# Direct deprecated packages
dotnet list package --deprecated
# Include transitive
dotnet list package --deprecated --include-transitive
Project 'OrderApi' has the following deprecated packages
[net10.0]:
Transitive Packages:
Resolved Reason(s) Alternative
> OldAuthLibrary 2.0.1 Legacy, Critical NewAuthLibrary
> UnmaintainedHelper 1.5.0 Legacy (none suggested)
The Reason(s) column distinguishes between packages deprecated as Legacy (superseded by a newer alternative) and those marked Critical (have security issues with no planned fix). The Alternative column points you to the recommended replacement when one exists.
Step 5: Identify Outdated Packages
Outdated is different from deprecated. An outdated package still receives updates, but you are behind. Newer versions may have patched vulnerabilities that affect you even though no CVE was filed against your current version. Find them with:
dotnet list package --outdated
# Include transitive packages that have newer versions available
dotnet list package --outdated --include-transitive
Project 'OrderApi' has the following updates available
[net10.0]:
Top-level Packages:
Requested Resolved Latest
> Dapper 2.1.35 2.1.35 2.1.44
> FluentValidation 11.10.0 11.10.0 11.11.0
Transitive Packages:
Resolved Latest
> System.Text.Json 8.0.5 10.0.1
The Latest column shows what is available on NuGet.org today. For transitive packages, you cannot update them directly — you must update the direct dependency that pulls them in (or add an explicit PackageReference to override the transitive version, which is a deliberate version pinning strategy).
Step 6: Override a Vulnerable Transitive Dependency
When a transitive package has a vulnerability and you cannot wait for the upstream direct dependency to release a fix, you can force a specific version by adding an explicit reference in your .csproj:
<ItemGroup>
<!-- Force a safe version of a transitive dependency -->
<!-- Use this only when the upstream package cannot be updated yet -->
<PackageReference Include="System.Text.Json" Version="10.0.1" />
</ItemGroup>
This tells NuGet to resolve System.Text.Json to the version you specify, overriding whatever the transitive graph would normally resolve. Add a comment explaining why, and track it as technical debt to remove once the upstream package ships the fix.
Putting It All Together: A CI Health Check Script
Combine all three checks into a single CI step that reports the full dependency health of your solution before every merge:
#!/bin/bash
# nuget-audit.sh — Run as a CI step before build
echo "=== Checking for vulnerable packages ==="
dotnet list package --vulnerable --include-transitive
echo ""
echo "=== Checking for deprecated packages ==="
dotnet list package --deprecated --include-transitive
echo ""
echo "=== Checking for outdated packages ==="
dotnet list package --outdated
# Exit non-zero if vulnerabilities are found (for CI gate)
VULN_COUNT=$(dotnet list package --vulnerable --include-transitive 2>&1 | grep -c "^ >")
if [ "$VULN_COUNT" -gt 0 ]; then
echo "ERROR: $VULN_COUNT vulnerable package(s) found. Resolve before merging."
exit 1
fi
This script is intentionally simple — it uses only the .NET SDK and standard shell tools. No additional tooling required, no external services, no tokens. Run it in GitHub Actions, Azure Pipelines, or any CI system that has the .NET SDK installed.
Understanding the Vulnerability Pipeline
Knowing where vulnerability data comes from helps you understand its reliability and lag time. The flow is:
Researcher discovers flaw
│
▼
CVE filed with MITRE / NVD
(National Vulnerability Database) ──or── Maintainer files directly
│ │
└─────────────────┬────────────────────────┘
▼
GitHub Security Advisory Database (GHSA)
│
▼
NuGet ingests GHSA into its Advisory Feed
│
▼
dotnet restore / dotnet list package --vulnerable
surfaces it in your build
The lag between a CVE being filed and NuGet surfacing it is typically hours to days, not weeks. This means dotnet list package --vulnerable reflects the current state of known advisories — making it suitable as a daily or per-PR check.
Summary
Dependency vulnerabilities are not a niche concern — they are one of the most common vectors for supply chain attacks on .NET applications. Here are the key takeaways:
- Your application's real package count is far larger than the PackageReference entries in your .csproj. Use dotnet list package --include-transitive to see the full picture.
- NuGet Audit runs automatically since .NET 8 — check your build output for vulnerability warnings after every dotnet restore.
- Use dotnet list package --vulnerable, --deprecated, and --outdated as three distinct health checks covering active CVEs, end-of-life packages, and version staleness respectively.
- Configure NuGetAuditLevel, NuGetAuditMode, and TreatWarningsAsErrors in Directory.Build.props or your .csproj to make vulnerability detection a CI gate, not just a warning you scroll past.
- When a transitive dependency is vulnerable and an upstream fix is not yet available, you can force a safe version using an explicit PackageReference override.
Security hygiene for packages is the same as security hygiene for your own code — you need to check it regularly, not just once during onboarding. Add the audit step to your CI pipeline today, before you need it.
Now that you can find vulnerabilities, the next skill is deciding what to do about them. Read Introduction to Logging in .NET to make sure you are capturing enough runtime context to investigate issues when they do occur, or explore OWASP — Secure Your App by Scanning for Vulnerable NuGet Dependencies in CI for the broader security posture story.