A .NET service you wrote six months ago and haven't touched can become vulnerable overnight — not because your code changed, but because a CVE was published against a NuGet package deep in its dependency tree. Dependency vulnerability scanning is how you find out before an attacker does. The good news is that .NET now ships capable scanning out of the box; the gap most teams have is turning it from a command you occasionally run into a gate that blocks risky releases. This guide covers the built-in tooling, its limits, and how to build a scanning workflow that actually holds.
Start with the built-in tools
The .NET SDK can enumerate vulnerable and deprecated packages without any third-party tooling. The core command:
dotnet list package --vulnerable --include-transitive
This resolves your project's full dependency graph and checks each package against the GitHub Advisory Database, printing the package, the resolved version, severity, and the advisory URL. The --include-transitive flag is essential — most real-world vulnerabilities live in dependencies-of-dependencies, not the packages you declared directly. Two companions round it out:
dotnet list package --deprecated # maintainer-flagged, often unmaintained
dotnet list package --outdated # newer versions available
Deprecated and abandoned packages matter because an unmaintained dependency won't get a patch when a vulnerability is found — the fix path becomes "replace it," which takes planning.
Turn on NuGet audit and fail the build
Running a command manually catches nothing on the day you forget. Since .NET 8, NuGet Audit runs during dotnet restore and emits warnings (NU1901–NU1904) for known-vulnerable packages; .NET 9 audits transitive dependencies by default. Configure it to be strict and blocking:
<PropertyGroup>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode> <!-- direct + transitive -->
<NuGetAuditLevel>moderate</NuGetAuditLevel> <!-- floor severity to warn on -->
<WarningsAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsAsErrors>
</PropertyGroup>
Now a restore in CI fails the moment a moderate-or-worse advisory matches your tree. This is the single most effective change for most teams: it moves scanning from "someone's responsibility" to "the build's responsibility."
Understand what the built-in tools don't do
The SDK tooling is a strong floor, not a ceiling. Its limits are worth naming so you know what to layer on:
- No reachability. It tells you a vulnerable package is present, not whether your code ever calls the vulnerable function. Most teams find the majority of flagged CVEs are in code paths they never execute, so undifferentiated alerts create fatigue.
- Advisory-database lag. Coverage depends on the advisory feed; newly disclosed issues and ecosystem-specific advisories can arrive late or incomplete.
- No fix orchestration. It won't open a pull request, test the bump, or tell you if the upgrade is breaking.
- Point-in-time. It reflects the moment you ran it; continuous monitoring against new disclosures is a separate capability.
This is where dedicated software composition analysis earns its place — adding reachability analysis, continuous monitoring, and remediation on top of the raw advisory match.
Build the scanning workflow into CI
A durable workflow has four stages. Wire them so each is automatic:
| Stage | Tool | Gate |
|---|---|---|
| On restore | NuGet audit | Fail on moderate+ advisories |
| On PR | dotnet list package --vulnerable + SCA | Block merge on new critical/high |
| Nightly | Full SCA scan of default branch | Ticket new findings |
| On release | SBOM generation + policy gate | Block ship on unremediated critical |
Here's a minimal CI step that fails when any vulnerable package is present:
dotnet restore --locked-mode
if dotnet list package --vulnerable --include-transitive | grep -q '>='; then
echo "Vulnerable packages found"; exit 1
fi
Run the same scan locally through the Safeguard CLI so developers catch issues before they push, and keep the CI gate as the backstop. Consistency between local and CI is what keeps scanning from becoming a merge-time surprise.
Prioritize and remediate without drowning
Finding vulnerabilities is easy; deciding what to fix first is the hard part. Rank by a combination of severity, reachability (does your code call it?), exploit availability, and whether a fix exists. A critical CVE in a method you never invoke is a lower priority than a high-severity one directly on your request path. When a fixed version exists, the fastest safe path is an automated upgrade PR that also runs your test suite — which is exactly what Safeguard's auto-fix engine produces, so remediation is a review-and-merge rather than a research project.
A scanning checklist
-
dotnet list package --vulnerable --include-transitiveruns in CI - NuGet audit on, transitive, warnings-as-errors at your severity floor
- SCA layered on for reachability + continuous monitoring
- Nightly scan of the default branch, not just PRs
- SBOM + policy gate at release
- Findings ranked by severity and reachability, not raw count
How Safeguard Helps
Safeguard takes .NET dependency scanning past the "list of CVEs" stage. It continuously monitors your NuGet dependency graph — direct and transitive — against fresh advisory data, and its reachability analysis tells you which flagged vulnerabilities actually touch code your application runs, so a team of three isn't triaging three hundred equal-looking alerts. Findings arrive prioritized by exploitability, and when a fix exists the auto-fix workflow opens a tested upgrade pull request. It runs from the CLI in any CI system and enforces policy gates that block a release with unremediated critical vulnerabilities. Teams comparing scanning approaches often look at the Safeguard vs Snyk page, and can see what's included at each tier on the pricing page.
Get started free at app.safeguard.sh/register and read the CI integration docs at docs.safeguard.sh.