Safeguard
Open Source Security

Finding vulnerable .NET dependencies with dotnet list pac...

A step-by-step guide to scanning C# projects for vulnerable NuGet packages using dotnet list package --vulnerable, plus how to fix and monitor them continuously.

Aman Khan
AppSec Engineer
7 min read

If you ship C# services, you're one transitive NuGet package away from shipping a known CVE. .NET's dependency graphs get deep fast — a single Microsoft.AspNetCore reference can pull in dozens of indirect packages you never explicitly chose, and any one of them can carry a disclosed vulnerability with a public exploit. The good news is that the .NET SDK ships a built-in way to check: dotnet list package vulnerable (technically dotnet list package --vulnerable), a command that cross-references your restored packages against the NuGet vulnerability database and tells you exactly what's exposed and how bad it is.

This guide walks through setting up and running dotnet list package --vulnerable across a real solution, reading its output correctly, fixing what it finds, and wiring it into CI so vulnerable dependencies get caught before merge — not after a pentest or an audit finds them for you.

Step 1: Confirm Your SDK and Project Setup

dotnet list package --vulnerable requires .NET SDK 5.0.200 or later — if you're on an older SDK, upgrade first, since the command silently doesn't exist before that. Check your version:

dotnet --version

You also need a restored project or solution, because the command reads from the resolved dependency graph, not just your .csproj file references. Restore before scanning:

dotnet restore

If you skip this step, the scan will either fail outright or report incomplete results, because it can't see transitive dependencies that haven't been resolved yet. This is the single most common cause of "it says I have no vulnerabilities" false negatives, so don't skip it even if you restored recently — package sources and lockfiles can drift.

Step 2: Run dotnet list package vulnerable on a Single Project

Start narrow so you understand the output format before scanning an entire solution. From a project directory:

dotnet list package --vulnerable

By default this only checks top-level, directly referenced packages. If everything comes back clean, don't celebrate yet — most real-world exposure hides in transitive dependencies, which this basic invocation ignores entirely. A clean result here is a starting point, not a verdict.

Step 3: Include Transitive Dependencies

This is the step that actually matters for C# dependency management, since the majority of exploitable CVEs in .NET projects arrive through packages you never directly referenced. Add the --include-transitive flag:

dotnet list package --vulnerable --include-transitive

Now the output walks the full graph. A typical hit looks like this:

The following sources were used:
   https://api.nuget.org/v3/index.json

Project `Checkout.Api` has the following vulnerable packages
   [net8.0]:
   Top-level Package      Requested   Resolved   Severity   Advisory URL
   > Newtonsoft.Json      12.0.1      12.0.1     High       https://github.com/advisories/GHSA-5crp-9r3c-p9vr

   [net8.0]:
   Transitive Package     Resolved   Severity   Advisory URL
   > System.Text.Encodings.Web   4.5.0   Moderate   https://github.com/advisories/GHSA-ghhp-997w-qr28

Note that transitive packages don't show a "Requested" version — you didn't request them, a dependency did, which is exactly why they're easy to miss during normal dependency management.

Step 4: Scan the Whole Solution at Once

Running this project-by-project doesn't scale past a handful of services. Point the command at the .sln file instead:

dotnet list solution.sln package --vulnerable --include-transitive

For repos with many projects, pipe the output to a file so you can diff scans over time and track whether your exposure is trending up or down:

dotnet list solution.sln package --vulnerable --include-transitive > vuln-report.txt

This is also the point where it's worth treating the exercise as a proper NuGet vulnerability audit rather than a one-off check — run it against every solution in the org, not just the service you're currently touching, since vulnerable transitive packages tend to be shared across projects via internal NuGet feeds or common base images.

Step 5: Prioritize by Severity, Not Just Count

The command reports four severity tiers: Low, Moderate, High, and Critical, each linked to a GitHub Security Advisory (GHSA) with CVSS scoring and exploit details. Don't treat the raw count of findings as your signal — ten Low-severity findings in an internal tool matter far less than one High-severity deserialization bug in a package that touches untrusted input.

A reasonable triage order:

  1. Critical/High severity in packages that process external input (HTTP bodies, file uploads, deserialization)
  2. Critical/High severity in anything internet-facing, regardless of what it processes
  3. Moderate severity in shared libraries referenced by multiple services
  4. Everything else, tracked but not blocking

Click through to the advisory URL before deciding — some CVEs only apply to specific usage patterns (a particular API call, a non-default config flag) that your code may not trigger at all.

Step 6: Remediate the Findings

Most fixes are a version bump. Update the flagged package directly:

dotnet add package Newtonsoft.Json --version 13.0.3

For transitive dependencies you don't reference directly, you generally can't dotnet add package your way out cleanly — instead, pin the fixed version explicitly in your .csproj so it wins resolution regardless of what pulled in the vulnerable one:

<ItemGroup>
  <PackageReference Include="System.Text.Encodings.Web" Version="6.0.0" />
</ItemGroup>

Re-run the scan after every fix to confirm the finding actually clears — sometimes a "fixed" version still resolves to the vulnerable one because of a floating version range elsewhere in the graph:

dotnet restore
dotnet list package --vulnerable --include-transitive

Step 7: Automate It in CI

Manual scans catch what's already in the repo; they don't stop the next vulnerable package from merging in. Add the check as a CI gate. A minimal GitHub Actions step:

- name: Check for vulnerable NuGet packages
  run: |
    dotnet restore
    dotnet list package --vulnerable --include-transitive | tee vuln-output.txt
    if grep -q "has the following vulnerable packages" vuln-output.txt; then
      echo "Vulnerable packages detected"
      exit 1
    fi

This turns .NET dependency scanning from an occasional manual chore into an enforced part of the merge process, which is the only version of this workflow that reliably prevents regressions.

Troubleshooting and Verification

Command returns nothing, even for a project you know has issues. Confirm the SDK version (5.0.200+) and that dotnet restore completed without errors first. An unrestored or partially restored project produces an empty or misleading result rather than an error.

Results differ between local runs and CI. This usually means different NuGet source configurations — check your nuget.config in both environments and make sure CI is hitting the same feeds, including api.nuget.org, and not just an internal mirror that may lag on advisory data.

A package you just updated still shows as vulnerable. Delete the obj and bin folders and restore again; stale restore caches can report resolved versions that no longer match your project file.

You need machine-readable output for tooling. Add --format json (SDK 8.0+) to get structured output you can parse instead of scraping text:

dotnet list package --vulnerable --include-transitive --format json

Verify a fix actually holds. Don't just trust that the scan came back clean once — re-run it after a dotnet restore --force to rule out cached resolution results, and check the advisory URL to confirm the version you shipped is above the "patched versions" threshold, not just different from the flagged one.

How Safeguard Helps

dotnet list package --vulnerable is a genuinely useful command, but it's a point-in-time, per-repo check — it tells you nothing about the twenty other .NET services in your org, doesn't track drift over time, and won't stop a vulnerable package from landing in a feature branch you're not currently looking at. Safeguard extends this same idea across your entire software supply chain: continuous NuGet vulnerability audit coverage across every repo, automatic correlation of new CVE disclosures against packages you already have deployed, and policy gates that block merges and builds when a dependency crosses your risk threshold — not just when someone remembers to run the scan.

For teams doing serious C# dependency management, Safeguard also maps vulnerable packages to actual runtime reachability, so you're not triaging every Moderate-severity transitive finding with the same urgency as a Critical one sitting directly in your request-handling path. Combined with SBOM generation and provenance tracking, it turns dotnet list package --vulnerable from a manual habit into an always-on guarantee — the check keeps running whether or not anyone remembers to type the command.

Never miss an update

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