As of .NET 10, defaults for NuGetAuditMode in NuGet Audit have changed, and this may mean additional warnings regarding NuGet package vulnerabilities (NU1901, NU1902, NU1903, NU1904, NU1905) in your .NET applications. After upgrading to .NET 10 from .NET 8, builds started reporting vulnerabilities that had not been reported before. GitHub vulnerability scanning hadn't spotted them, and this seems to be because the vulnerable packages were not directly referenced in the app (because they are transitive (indirect) references via other directly referenced packages).
Microsoft documentation says:
NuGetAuditMode defaults to all when a project targets net10.0 or higher. Otherwise NuGetAuditMode defaults to direct. When a project multi-targets, if any one target framework selects all, then audit will use this value for all target frameworks.
https://learn.microsoft.com/en-us/nuget/concepts/auditing-packages#configuring-nuget-audit
To Summarise:
Previous Default: In older versions of the .NET SDK (pre-10), the NuGet Audit feature was sometimes set to only check direct dependencies by default (NuGetAuditMode set to direct).
New .NET 10 Default: When a project targets net10.0 or higher, the default for the audit mode changes to all.
This means that when you run dotnet restore or build your project, the system now automatically scans both direct and transitive dependencies for vulnerabilities.
NuGetAuditMode:
| MSBuild Property | Default | Possible values | Notes |
|---|---|---|---|
| NuGetAuditMode | See 1 below | direct and all |
If you'd like to audit top-level dependencies only, you can set the value to direct. NuGetAuditMode is not applicable for packages.config projects. |
| NuGetAuditLevel | low | low, moderate, high, and critical |
The minimum severity level to report. If you'd like to see moderate, high, and critical advisories (exclude low), set the value to moderate |
| NuGetAudit | true | true and false |
If you wish to not receive security audit reports, you can opt-out of the experience entirely by setting the value to false |
The Challenge: Identifying Root Packages for Transitive Dependencies
Manually overriding transitive package versions in a .csproj file can be brittle and high-maintenance in large projects. The risk of introducing a breaking change to the primary package is a possibility. We also do not want to add configuration for packages that we do not have a direct dependency on.
The aim is to identify the root packages and upgrade those.
Identification can be achieved using 2 dotnet utilities:
> dotnet list [solution] package --vulnerable
> dotnet nuget why [solution] [packageName]
Running these commands manually for all reported vulnerable packages can be arduous in large projects, and so I have created a C# file based app (file based apps are new in .NET 10 - read more here https://www.appsoftware.com/blog/net-10-file-based-apps-demonstration-c-scripting) to automatically list vulnerable packages, and then call dotnet nuget why and parse the output to link the vulnerability to the root package.
Prerequisites
- .NET 10 SDK installed (verify with
dotnet --version). This is required to run file based apps.
How to Run
Using dotnet run
Run the file based app using dotnet run
dotnet run VulnerabilityScanner.cs
How it Works
The script:
- Scans for Solutions: Identifies solution files (
.sln) in the current directory. - Identifies Vulnerabilities: Finds all packages with known security issues using
dotnet list package --vulnerable - Traces Root Causes: Determines which top-level packages are bringing in vulnerable transitive dependencies
- Generates Report: Creates
vulnerabilities_<datestamp>.mdreport.
Script (C# File Based App):
Save as VulnerabilityScanner.cs in the same directory as your .sln file(s) and run with dotnet run --file VulnerabilityScanner.cs
This script is maintained on GitHub
// > dotnet run --file VulnerabilityScanner.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
Console.WriteLine("Starting vulnerability scan...");
// Automatically discover all solution files in the current directory
var solutions = Directory.GetFiles(Directory.GetCurrentDirectory(), "*.sln")
.Select(Path.GetFileName)
.Where(f => f != null)
.Cast<string>()
.ToArray();
if (solutions.Length == 0)
{
Console.WriteLine("Error: No solution files (.sln) found in the current directory.");
Environment.Exit(1);
}
Console.WriteLine($"Found {solutions.Length} solution file(s): {string.Join(", ", solutions)}");
var allVulnerabilities = new List<VulnerabilityInfo>();
foreach (var solution in solutions)
{
if (!File.Exists(solution))
{
Console.WriteLine($"Warning: Solution file not found: {solution}");
continue;
}
Console.WriteLine($"\nScanning {solution}...");
// Run dotnet list package --vulnerable
var vulnerableOutput = RunCommand("dotnet", $"list \"{solution}\" package --vulnerable");
var vulnerabilities = ParseVulnerabilities(vulnerableOutput, solution);
// Get distinct package names to avoid redundant lookups
var distinctPackages = vulnerabilities
.Select(v => v.PackageName)
.Distinct()
.ToList();
// Build a cache of root packages for each unique vulnerable package
var rootPackageCache = new Dictionary<string, List<string>>();
foreach (var packageName in distinctPackages)
{
Console.WriteLine($" Finding root cause for {packageName}...");
rootPackageCache[packageName] = FindRootPackages(packageName, solution);
}
// Assign root packages from cache to each vulnerability
foreach (var vuln in vulnerabilities)
{
vuln.RootPackages = rootPackageCache[vuln.PackageName];
}
allVulnerabilities.AddRange(vulnerabilities);
}
// Generate markdown report
GenerateMarkdownReport(allVulnerabilities);
Console.WriteLine("\nVulnerability scan complete! Report saved to vulnerabilities_<datestamp>.md");
// Helper methods
string RunCommand(string command, string arguments)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = command,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = Directory.GetCurrentDirectory()
}
};
var output = new StringBuilder();
process.OutputDataReceived += (sender, e) => { if (e.Data != null) output.AppendLine(e.Data); };
process.ErrorDataReceived += (sender, e) => { if (e.Data != null) output.AppendLine(e.Data); };
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
process.WaitForExit();
return output.ToString();
}
List<VulnerabilityInfo> ParseVulnerabilities(string output, string solution)
{
var vulnerabilities = new List<VulnerabilityInfo>();
var lines = output.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
// Match MSBuild warning format: path\project.csproj : warning NUXXX: Package 'Name' Version has a known severity vulnerability, URL
var warningMatch = Regex.Match(line,
@"([^:]+\.csproj)\s*:\s*warning\s+(NU\d+):\s*Package\s+'([^']+)'\s+([^\s]+)\s+has\s+a\s+known\s+(\w+)\s+severity\s+vulnerability,\s+(https://[^\s\[\]]+)",
RegexOptions.IgnoreCase);
if (warningMatch.Success)
{
var projectPath = warningMatch.Groups[1].Value.Trim();
var packageName = warningMatch.Groups[3].Value;
var version = warningMatch.Groups[4].Value;
var severity = warningMatch.Groups[5].Value;
var advisoryUrl = warningMatch.Groups[6].Value;
vulnerabilities.Add(new VulnerabilityInfo
{
Solution = solution,
Project = projectPath,
PackageName = packageName,
Version = version,
Severity = severity,
AdvisoryUrl = advisoryUrl,
RootPackages = new List<string>()
});
}
}
return vulnerabilities;
}
List<string> FindRootPackages(string packageName, string solution)
{
var rootPackages = new List<string>();
// Get list of projects in the solution to filter them out from dependencies
var projectsOutput = RunCommand("dotnet", $"sln \"{solution}\" list");
var solutionProjects = projectsOutput.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Where(line => line.EndsWith(".csproj"))
.Select(line => Path.GetFileNameWithoutExtension(line))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Use dotnet nuget why to find the dependency chain
var whyOutput = RunCommand("dotnet", $"nuget why \"{solution}\" {packageName}");
var lines = whyOutput.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
string? currentProject = null;
string? rootPackage = null;
bool inDependencyGraph = false;
foreach (var line in lines)
{
// Match project line: "Project 'ProjectName' has the following dependency graph(s) for 'PackageName'"
var projectMatch = Regex.Match(line, @"Project\s+'([^']+)'\s+has\s+the\s+following\s+dependency");
if (projectMatch.Success)
{
currentProject = projectMatch.Groups[1].Value;
rootPackage = null;
inDependencyGraph = true;
continue;
}
// Skip "does not have a dependency" lines
if (line.Contains("does not have a dependency"))
{
inDependencyGraph = false;
continue;
}
// Skip framework lines like "[net10.0]"
if (line.Trim().StartsWith("[") && line.Trim().EndsWith("]"))
{
continue;
}
// Look for the first package in the dependency tree (the root cause)
// It will be the first line with a package name after the framework line
if (inDependencyGraph && currentProject != null && rootPackage == null)
{
// Match lines with package names and versions: " └─ PackageName (vX.X.X)"
var packageMatch = Regex.Match(line, @"[└├─│\s]+([A-Za-z0-9_.]+)\s+\(v([\d.]+)\)");
if (packageMatch.Success)
{
var pkg = packageMatch.Groups[1].Value;
// This is the root package that brings in the vulnerable dependency
// Filter out project references from the solution and the target package itself
if (!pkg.Equals(packageName, StringComparison.OrdinalIgnoreCase) &&
!solutionProjects.Contains(pkg))
{
rootPackage = pkg;
// Just add the root package name, not the project
if (!rootPackages.Contains(rootPackage))
{
rootPackages.Add(rootPackage);
}
inDependencyGraph = false; // Stop looking for this project
}
}
}
}
return rootPackages.Any() ? rootPackages : new List<string> { "No dependency found" };
}
void GenerateMarkdownReport(List<VulnerabilityInfo> vulnerabilities)
{
var markdown = new StringBuilder();
markdown.AppendLine("# NuGet Package Vulnerability Report");
markdown.AppendLine();
markdown.AppendLine($"**Generated:** {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
markdown.AppendLine();
markdown.AppendLine($"**Total Vulnerabilities Found:** {vulnerabilities.Count}");
markdown.AppendLine();
// Group by solution
var bySolution = vulnerabilities.GroupBy(v => v.Solution);
foreach (var solutionGroup in bySolution)
{
markdown.AppendLine($"## Solution: {solutionGroup.Key}");
markdown.AppendLine();
// Group by severity
var bySeverity = solutionGroup.GroupBy(v => v.Severity)
.OrderByDescending(g => GetSeverityOrder(g.Key));
foreach (var severityGroup in bySeverity)
{
// Group by package/version/project to consolidate duplicate entries with different advisories
var consolidatedVulns = severityGroup
.GroupBy(v => new { v.PackageName, v.Version, v.Project, RootPackages = string.Join(",", v.RootPackages.OrderBy(x => x)) })
.Select(g => new
{
g.Key.PackageName,
g.Key.Version,
g.Key.Project,
RootPackages = g.First().RootPackages,
Advisories = g.Select(v => v.AdvisoryUrl).Distinct().ToList()
})
.OrderBy(v => v.PackageName)
.ThenBy(v => Path.GetFileName(v.Project))
.ToList();
markdown.AppendLine($"### {severityGroup.Key} Severity ({consolidatedVulns.Count})");
markdown.AppendLine();
markdown.AppendLine("| Package | Version | Project | Root Package(s) | Advisory |");
markdown.AppendLine("|---------|---------|---------|-----------------|----------|");
foreach (var vuln in consolidatedVulns)
{
var rootPackagesStr = string.Join("<br/>", vuln.RootPackages.Take(3));
if (vuln.RootPackages.Count > 3)
{
rootPackagesStr += $"<br/>...and {vuln.RootPackages.Count - 3} more";
}
var projectName = Path.GetFileName(vuln.Project);
// Consolidate multiple advisories into numbered links
string advisoryLinks;
if (vuln.Advisories.Count == 1)
{
advisoryLinks = $"[Link]({vuln.Advisories[0]})";
}
else
{
advisoryLinks = string.Join("<br/>", vuln.Advisories.Select((url, idx) => $"[Link {idx + 1}]({url})"));
}
markdown.AppendLine($"| {vuln.PackageName} | {vuln.Version} | {projectName} | {rootPackagesStr} | {advisoryLinks} |");
}
markdown.AppendLine();
}
}
// Summary section
markdown.AppendLine("## Summary by Package");
markdown.AppendLine();
var byPackage = vulnerabilities.GroupBy(v => v.PackageName)
.OrderByDescending(g => g.Count());
markdown.AppendLine("| Package | Occurrences | Severity | Affected Versions |");
markdown.AppendLine("|---------|-------------|----------|-------------------|");
foreach (var packageGroup in byPackage)
{
var count = packageGroup.Count();
var severity = packageGroup.First().Severity;
var versions = string.Join(", ", packageGroup.Select(v => v.Version).Distinct());
markdown.AppendLine($"| {packageGroup.Key} | {count} | {severity} | {versions} |");
}
markdown.AppendLine();
markdown.AppendLine("## Recommended Actions");
markdown.AppendLine();
markdown.AppendLine("1. **High Severity**: Update immediately");
markdown.AppendLine("2. **Moderate Severity**: Plan update in next sprint");
markdown.AppendLine("3. **Low Severity**: Update during regular maintenance");
markdown.AppendLine();
markdown.AppendLine("### How to Update Packages");
markdown.AppendLine();
markdown.AppendLine("```bash");
markdown.AppendLine("# Update a specific package in a project");
markdown.AppendLine("dotnet add [project] package [PackageName] --version [LatestVersion]");
markdown.AppendLine();
markdown.AppendLine("# For transitive dependencies, add explicit reference");
markdown.AppendLine("# Example:");
markdown.AppendLine("# dotnet add package System.Text.Json --version 10.0.0");
markdown.AppendLine("```");
File.WriteAllText($"vulnerabilities_{DateTime.Now.ToString("yyyy_MM_dd_HHmmss")}.md", markdown.ToString());
}
int GetSeverityOrder(string severity)
{
return severity.ToLower() switch
{
"high" => 3,
"moderate" => 2,
"low" => 1,
_ => 0
};
}
class VulnerabilityInfo
{
public required string Solution { get; set; }
public required string Project { get; set; }
public required string PackageName { get; set; }
public required string Version { get; set; }
public required string Severity { get; set; }
public required string AdvisoryUrl { get; set; }
public required List<string> RootPackages { get; set; }
}
Example Report Output:
NuGet Package Vulnerability Report
Generated: 2025-11-27 19:09:40
Total Vulnerabilities Found: 14
Solution: ExampleApp.sln
moderate Severity (11)
| Package | Version | Project | Root Package(s) | Advisory |
|---|---|---|---|---|
| Azure.Identity | 1.10.3 | ExampleApp.Web.csproj | dbup | Link |
| Azure.Identity | 1.10.3 | ExampleApp.UnitTests.csproj | dbup | Link |
low Severity (3)
| Package | Version | Project | Root Package(s) | Advisory |
|---|---|---|---|---|
| Microsoft.Identity.Client | 4.56.0 | ExampleApp.UnitTests.csproj | dbup | Link |
| Microsoft.Identity.Client | 4.56.0 | ExampleApp.Web.csproj | dbup | Link |
Summary by Package
| Package | Occurrences | Severity | Affected Versions |
|---|---|---|---|
| Azure.Identity | 2 | moderate | 1.10.3 |
| Microsoft.Identity.Client | 2 | moderate | 4.56.0 |
Recommended Actions
- High Severity: Update immediately
- Moderate Severity: Plan update in next sprint
- Low Severity: Update during regular maintenance
How to Update Packages
# Update a specific package in a project
dotnet add [project] package [PackageName] --version [LatestVersion]
# For transitive dependencies, add explicit reference
# Example:
# dotnet add package System.Text.Json --version 10.0.0
Summary
Hopefully this post and script help you to manage transitive NuGet package vulnerabilities in your .NET apps.
References: