July 31, 2026
Secure NuGet Dependencies in .NET 10: Build a CI Policy Gate
Turn transitive vulnerability audits into severity policy, expiring waivers, fail-closed evidence, and SARIF — not permanent suppressions.

By Michael Maurice
9 min read
A vulnerability warning is evidence, not a policy.
If every advisory breaks the build, developers eventually learn to suppress the scanner. If none do, the scanner is theater. The useful middle is a control that can answer four questions consistently:
-
Which findings are serious enough to stop delivery?
-
Did the audit actually consult a vulnerability source?
-
Can a team accept risk temporarily without hiding it forever?
-
Can the decision travel into CI and code-scanning tools?
.NET 10 makes this problem more visible because NuGet now audits transitive packages by default. That is a valuable change: vulnerable code is not safer because it arrived through another package. But a larger discovery surface also makes policy quality more important.
In this article, we will build NuGet Policy Guard, a production-oriented .NET 10 command-line tool that runs NuGet's own audit, validates the machine-readable evidence, enforces a severity threshold, supports exact and expiring waivers, and emits JSON plus SARIF.
This article focuses on the decisions that make the control trustworthy. The complete .NET 10 solution — including source code, tests, Docker packaging, policy examples, and documentation — is available through the Tech Skill Builder Community.
Why NuGet audit policy matters now
NuGet auditing is not new, but the defaults and tooling have matured. In .NET 10, transitive packages are included in auditing by default. The SDK can also emit a versioned JSON report from dotnet package list, while dotnet package update --vulnerable provides a separate remediation workflow.
Package pruning in .NET 10 matters here too. It removes unnecessary package references from the resolved graph, reducing noise before policy evaluation. Microsoft reported 70% fewer transitive vulnerability reports in its telemetry after pruning. That figure describes Microsoft's observed population, not a guarantee for your repository, but it points in the right direction: accurate dependency graphs make security findings more actionable.
NuGet already maps known vulnerabilities to NU1901 through NU1904 by severity. It also uses NU1905 when an audit source cannot be used. Those warnings are useful developer feedback, but turning all of them into errors is a fairly blunt CI strategy. A moderate advisory in an unreachable code path and a critical advisory on an internet-facing parser should not automatically produce the same organizational decision.
The policy layer should not compete with NuGet's discovery. It should preserve the evidence, make the delivery decision explicit, and leave remediation to an intentional update workflow.
What we are building
NuGet Policy Guard has two input modes.
In live mode, it launches the .NET 10 CLI against a project or solution:
dotnet run --project src/NuGetPolicyGuard.Cli -- `
--target NuGetPolicyGuard.slnx `
--policy config/policy.json `
--exceptions config/exceptions.json `
--output artifacts/livedotnet run --project src/NuGetPolicyGuard.Cli -- `
--target NuGetPolicyGuard.slnx `
--policy config/policy.json `
--exceptions config/exceptions.json `
--output artifacts/liveIn evidence mode, it evaluates a previously captured JSON report:
dotnet run --project src/NuGetPolicyGuard.Cli -- `
--input fixtures/vulnerable-report.json `
--policy config/policy.json `
--exceptions config/exceptions.json `
--output artifacts/blockingdotnet run --project src/NuGetPolicyGuard.Cli -- `
--input fixtures/vulnerable-report.json `
--policy config/policy.json `
--exceptions config/exceptions.json `
--output artifacts/blockingThe exit contract is deliberately small: 0 means policy passed, 2 means the evidence was valid but policy failed, and 3 means the tool could not produce a trustworthy decision.
The output directory receives:
policy-report.json, containing the parsed audit and full policy decisionpolicy-report.sarif, suitable for CI artifact retention or code-scanning ingestion
The checked-in vulnerable report is synthetic. It contains fictional Contoso.* packages, so the test suite remains deterministic and never misrepresents a real advisory.
Architecture: a policy pipeline, not a framework collection
This problem does not need a web API, database, message broker, or a ceremonial multi-layer architecture. It needs a deterministic pipeline with strict boundaries:
dotnet package list
|
v
versioned JSON evidence
|
v
schema + source validation
|
v
severity policy + exact waivers
|
+------> JSON decision
|
+------> SARIF result
|
v
CI exit codedotnet package list
|
v
versioned JSON evidence
|
v
schema + source validation
|
v
severity policy + exact waivers
|
+------> JSON decision
|
+------> SARIF result
|
v
CI exit codeThe solution contains a dependency-free core library, a thin CLI host, and an xUnit test project. The CLI owns process execution, cancellation, dependency injection, and structured logging. The core owns parsing, configuration, evaluation, and reports.
NuGetPolicyGuard/
├── src/
│ ├── NuGetPolicyGuard.Core/
│ └── NuGetPolicyGuard.Cli/
├── tests/NuGetPolicyGuard.Tests/
├── config/
├── fixtures/
├── docs/
├── artifacts/
├── Dockerfile
└── NuGetPolicyGuard.slnxNuGetPolicyGuard/
├── src/
│ ├── NuGetPolicyGuard.Core/
│ └── NuGetPolicyGuard.Cli/
├── tests/NuGetPolicyGuard.Tests/
├── config/
├── fixtures/
├── docs/
├── artifacts/
├── Dockerfile
└── NuGetPolicyGuard.slnxThat boundary is useful rather than decorative. Policy tests do not start child processes, while the CLI can later be replaced by a build task or service without rewriting the decision engine.
Capture NuGet's evidence safely
The live runner invokes the noun-first .NET 10 command, requests transitive vulnerabilities, pins JSON output version 1, and uses --no-restore:
public static async Task<string> RunAsync(
string target,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (var argument in new[]
{
"package", "list", "--project", target, "--vulnerable",
"--include-transitive", "--format", "json",
"--output-version", "1", "--no-restore"
})
startInfo.ArgumentList.Add(argument);
using var process = new Process { StartInfo = startInfo };
if (!process.Start())
throw new InvalidOperationException("Could not start the dotnet audit process.");
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var output = await outputTask;
var error = await errorTask;
if (process.ExitCode != 0)
throw new InvalidOperationException(
$"dotnet package list failed with exit code {process.ExitCode}: {error.Trim()}");
if (string.IsNullOrWhiteSpace(output))
throw new InvalidDataException("dotnet package list returned an empty report.");
return output;
}public static async Task<string> RunAsync(
string target,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (var argument in new[]
{
"package", "list", "--project", target, "--vulnerable",
"--include-transitive", "--format", "json",
"--output-version", "1", "--no-restore"
})
startInfo.ArgumentList.Add(argument);
using var process = new Process { StartInfo = startInfo };
if (!process.Start())
throw new InvalidOperationException("Could not start the dotnet audit process.");
var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken);
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
await process.WaitForExitAsync(cancellationToken);
var output = await outputTask;
var error = await errorTask;
if (process.ExitCode != 0)
throw new InvalidOperationException(
$"dotnet package list failed with exit code {process.ExitCode}: {error.Trim()}");
if (string.IsNullOrWhiteSpace(output))
throw new InvalidDataException("dotnet package list returned an empty report.");
return output;
}ProcessStartInfo.ArgumentList is important. Concatenating a user-controlled project path into a shell command creates an injection boundary for no good reason. UseShellExecute = false, a bounded cancellation token, redirected error output, and explicit exit validation make failure observable.
--no-restore is also intentional. CI should perform a visible restore first, using the repository's locked configuration and authenticated feeds, then audit the graph it actually restored. Hiding restore inside the scanner makes network and credential failures harder to diagnose.
Pin and validate the report schema
Machine-readable output is a contract. Accepting whatever shape happens to arrive can turn a format change into a false pass.
The parser requires schema version 1, requires a projects array, records whether audit sources were present, and flattens both direct and transitive packages:
public static AuditDocument Parse(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
using var document = JsonDocument.Parse(
json,
new() { CommentHandling = JsonCommentHandling.Disallow });
var root = document.RootElement;
var schemaVersion = RequiredInt(root, "version");
if (schemaVersion != 1)
throw new InvalidDataException(
$"Unsupported NuGet package-list schema version '{schemaVersion}'.");
var hasSources = root.TryGetProperty("sources", out var sources) &&
sources.ValueKind == JsonValueKind.Array &&
sources.GetArrayLength() > 0;
if (!root.TryGetProperty("projects", out var projects) ||
projects.ValueKind != JsonValueKind.Array)
throw new InvalidDataException(
"NuGet report does not contain a projects array.");
var findings = new List<VulnerabilityFinding>();
foreach (var project in projects.EnumerateArray())
{
var projectPath = RequiredString(project, "path");
if (!project.TryGetProperty("frameworks", out var frameworks))
continue;
foreach (var framework in frameworks.EnumerateArray())
{
var frameworkName = RequiredString(framework, "framework");
ReadPackages(framework, "topLevelPackages", false,
projectPath, frameworkName, findings);
ReadPackages(framework, "transitivePackages", true,
projectPath, frameworkName, findings);
}
}
return new(schemaVersion, hasSources, projects.GetArrayLength(), findings);
}public static AuditDocument Parse(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
using var document = JsonDocument.Parse(
json,
new() { CommentHandling = JsonCommentHandling.Disallow });
var root = document.RootElement;
var schemaVersion = RequiredInt(root, "version");
if (schemaVersion != 1)
throw new InvalidDataException(
$"Unsupported NuGet package-list schema version '{schemaVersion}'.");
var hasSources = root.TryGetProperty("sources", out var sources) &&
sources.ValueKind == JsonValueKind.Array &&
sources.GetArrayLength() > 0;
if (!root.TryGetProperty("projects", out var projects) ||
projects.ValueKind != JsonValueKind.Array)
throw new InvalidDataException(
"NuGet report does not contain a projects array.");
var findings = new List<VulnerabilityFinding>();
foreach (var project in projects.EnumerateArray())
{
var projectPath = RequiredString(project, "path");
if (!project.TryGetProperty("frameworks", out var frameworks))
continue;
foreach (var framework in frameworks.EnumerateArray())
{
var frameworkName = RequiredString(framework, "framework");
ReadPackages(framework, "topLevelPackages", false,
projectPath, frameworkName, findings);
ReadPackages(framework, "transitivePackages", true,
projectPath, frameworkName, findings);
}
}
return new(schemaVersion, hasSources, projects.GetArrayLength(), findings);
}Notice the distinction between "no findings" and "no source." An empty vulnerability list from a known audit source can pass. A report with no audit source cannot prove that the dependency graph was checked, so our default policy fails closed.
The parser also rejects unknown severities and missing advisory URLs. Leniency feels convenient until it silently converts new security data into ignored data.
Express policy as configuration
The default policy is short enough to review:
{
"minimumBlockingSeverity": "High",
"failWhenAuditSourcesMissing": true,
"requireExceptionOwner": true,
"maximumExceptionDays": 30
}{
"minimumBlockingSeverity": "High",
"failWhenAuditSourcesMissing": true,
"requireExceptionOwner": true,
"maximumExceptionDays": 30
}High and critical findings block. Low and moderate findings remain visible as observations. Organizations should choose a threshold based on risk appetite, exposure, remediation capacity, and regulatory obligations — not copy this value without discussion.
Configuration is strongly typed and validated. The maximum waiver duration must be between one and 365 days, and the severity must be a defined enum value. A malformed policy is an operational error, never an implicit allow.
Make risk acceptance narrow and temporary
A global NoWarn entry can make a dashboard green while leaving the vulnerability in production. The safer escape hatch is an exact waiver keyed by both package and advisory:
[
{
"packageId": "Contoso.Direct",
"advisoryUrl": "https://github.com/advisories/GHSA-demo-critical",
"grantedOn": "2026-07-30",
"expiresOn": "2026-08-15",
"owner": "platform-security",
"reason": "Upgrade is scheduled and tracked."
}
][
{
"packageId": "Contoso.Direct",
"advisoryUrl": "https://github.com/advisories/GHSA-demo-critical",
"grantedOn": "2026-07-30",
"expiresOn": "2026-08-15",
"owner": "platform-security",
"reason": "Upgrade is scheduled and tracked."
}
]The evaluator does not treat this as a suppression. It produces an Excepted finding and a SARIF warning, preserving the accepted risk in the output.
private static EvaluatedFinding EvaluateFinding(
VulnerabilityFinding finding,
AuditPolicy policy,
IReadOnlyList<PolicyWaiver> exceptions,
DateOnly today)
{
if (finding.Severity < policy.MinimumBlockingSeverity)
return new(finding, FindingDisposition.Observed,
"below_policy_threshold");
var exception = exceptions.SingleOrDefault(item =>
string.Equals(item.PackageId, finding.PackageId,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(item.AdvisoryUrl, finding.AdvisoryUrl,
StringComparison.Ordinal));
if (exception is null)
return new(finding, FindingDisposition.Blocked, "no_exception");
if (exception.ExpiresOn < today)
return new(finding, FindingDisposition.Blocked, "exception_expired");
if (exception.GrantedOn > today ||
exception.ExpiresOn < exception.GrantedOn)
return new(finding, FindingDisposition.Blocked,
"invalid_exception_dates");
if (exception.ExpiresOn.DayNumber - exception.GrantedOn.DayNumber >
policy.MaximumExceptionDays)
return new(finding, FindingDisposition.Blocked, "exception_too_long");
if (string.IsNullOrWhiteSpace(exception.Reason))
return new(finding, FindingDisposition.Blocked,
"exception_reason_missing");
if (policy.RequireExceptionOwner &&
string.IsNullOrWhiteSpace(exception.Owner))
return new(finding, FindingDisposition.Blocked,
"exception_owner_missing");
return new(finding, FindingDisposition.Excepted, "active_exception");
}private static EvaluatedFinding EvaluateFinding(
VulnerabilityFinding finding,
AuditPolicy policy,
IReadOnlyList<PolicyWaiver> exceptions,
DateOnly today)
{
if (finding.Severity < policy.MinimumBlockingSeverity)
return new(finding, FindingDisposition.Observed,
"below_policy_threshold");
var exception = exceptions.SingleOrDefault(item =>
string.Equals(item.PackageId, finding.PackageId,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(item.AdvisoryUrl, finding.AdvisoryUrl,
StringComparison.Ordinal));
if (exception is null)
return new(finding, FindingDisposition.Blocked, "no_exception");
if (exception.ExpiresOn < today)
return new(finding, FindingDisposition.Blocked, "exception_expired");
if (exception.GrantedOn > today ||
exception.ExpiresOn < exception.GrantedOn)
return new(finding, FindingDisposition.Blocked,
"invalid_exception_dates");
if (exception.ExpiresOn.DayNumber - exception.GrantedOn.DayNumber >
policy.MaximumExceptionDays)
return new(finding, FindingDisposition.Blocked, "exception_too_long");
if (string.IsNullOrWhiteSpace(exception.Reason))
return new(finding, FindingDisposition.Blocked,
"exception_reason_missing");
if (policy.RequireExceptionOwner &&
string.IsNullOrWhiteSpace(exception.Owner))
return new(finding, FindingDisposition.Blocked,
"exception_owner_missing");
return new(finding, FindingDisposition.Excepted, "active_exception");
}The clock is injected through .NET's TimeProvider. That removes time-dependent test flakiness and lets tests place themselves precisely before or after expiry. Duplicate package/advisory entries are rejected because ambiguous risk decisions should not depend on file ordering.
In a larger organization, add a ticket URL, approval identity, affected environment, and justification taxonomy. Keep the essential properties: exact scope, named ownership, visible reason, and automatic expiration.
Preserve the decision in JSON and SARIF
A console line disappears. A report can be retained, inspected, compared, and ingested.
NuGet Policy Guard maps blocked findings to SARIF error, active waivers to warning, and below-threshold observations to note. Each result carries the package, resolved version, severity, reason code, project path, and advisory URL.
This output is intentionally separate from NuGet's raw evidence. The raw report says what the SDK found. The policy report says what the organization decided. Retain both if your audit or incident-response requirements need reconstructable evidence.
Be careful with SARIF upload permissions in pull requests from forks. A workflow that receives untrusted code should have the minimum token permissions and should not expose feed credentials. The scanner itself does not require write access to source.
Structured logs, cancellation, and failure semantics
The CLI host uses dependency injection for TimeProvider and PolicyEvaluator, JSON console logging, and a scoped target/input context. File and process work is asynchronous and accepts a cancellation token derived from a configurable timeout.
Expected operational failures — bad arguments, invalid JSON, missing files, inaccessible output, or a failed child process — return exit code 3 with a structured error. A timeout has the same exit class but a distinct message.
That separation is valuable in CI:
- Exit
2: engineering policy rejected valid evidence. - Exit
3: the control itself did not complete reliably.
Do not convert exit 3 into a pass. A broken scanner and a clean scan are not equivalent.
Testing the decisions that can fail
The xUnit suite contains ten tests covering:
- direct and transitive package parsing
- rejection of an unknown schema version
- high severity blocking and moderate observation
- acceptance of an active, owned waiver
- rejection of expired and overlong waivers
- rejection of a missing owner
- fail-closed behavior when audit sources are absent
- duplicate waiver rejection
- JSON and SARIF generation
One representative test uses a fixed clock:
[Fact]
public void MissingAuditSourceFailsClosed()
{
var audit = NuGetAuditParser.Parse(
ReportJson().Replace(
"\"sources\": [\"https://api.nuget.org/v3/index.json\"],",
"",
StringComparison.Ordinal));
var result = Evaluator().Evaluate(audit, Policy(), []);
Assert.False(result.Passed);
Assert.False(result.AuditSourcesAvailable);
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}[Fact]
public void MissingAuditSourceFailsClosed()
{
var audit = NuGetAuditParser.Parse(
ReportJson().Replace(
"\"sources\": [\"https://api.nuget.org/v3/index.json\"],",
"",
StringComparison.Ordinal));
var result = Evaluator().Evaluate(audit, Policy(), []);
Assert.False(result.Passed);
Assert.False(result.AuditSourcesAvailable);
}
private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}The verified Release build completed with zero warnings and zero errors. All ten tests passed. The synthetic critical report exited 2 with one blocked and one observed finding. Applying the owned, unexpired waiver changed that run to exit 0 with one excepted and one observed finding.
Finally, I ran the live audit against the solution itself. It inspected three projects through the configured NuGet audit source and found zero known vulnerabilities at that moment. That is a time-bound scan result, not a claim that the software is free of every dependency risk.
Production deployment considerations
The repository includes a multi-stage Dockerfile, but containerizing the gate is optional. The important property is that its .NET SDK version and feed configuration are controlled. A few production practices deserve attention:
Pin the SDK. The included global.json pins .NET SDK 10.0.300. Review upgrades deliberately, especially when consuming a versioned CLI format.
Separate restore from evaluation. Restore with the correct authenticated feeds, lock files, and source mapping where appropriate. Then run the policy gate with --no-restore.
Configure audit sources explicitly. If package sources and audit sources differ, declare the intended audit endpoints in NuGet.Config. Treat NU1905 and missing source evidence as control failures unless your risk process explicitly says otherwise.
Retain evidence. Store raw NuGet JSON, the evaluated JSON report, SARIF, SDK version, and policy revision for an appropriate period.
Automate expiry pressure. Run the gate on a schedule as well as pull requests. Otherwise, an expired waiver may remain unnoticed in an inactive repository.
Protect policy changes. Require code owners for policy and waiver files. A beautifully tested gate is useless if any contributor can grant themselves a year-long exception.
Remediate separately. Let the gate identify unacceptable risk, then use a controlled update workflow, regression tests, and staged deployment. Automatically changing dependency versions in the same trusted step that judges policy expands the blast radius.
Common mistakes
The first mistake is turning every NuGet warning into an error without a risk model. That often creates suppression fatigue.
The second is checking only direct packages. A transitive dependency still executes in your process, and .NET 10's default reflects that reality.
The third is treating scanner unavailability as "no vulnerabilities." Fail closed, alert on the operational problem, and restore service quickly.
The fourth is creating broad exceptions by package name alone. One safe advisory should not waive another advisory discovered tomorrow.
The fifth is allowing permanent waivers. Risk acceptance without an owner and deadline is usually just hidden backlog.
Finally, do not confuse a vulnerability database match with exploitability analysis. Severity is a useful default signal, not complete context. Internet exposure, reachable code, compensating controls, data sensitivity, and active exploitation can all change priority.
Trade-offs and alternatives
This implementation evaluates severity rather than CVSS vectors, exploit prediction, reachability, or environment-specific exposure. That keeps the policy understandable and portable, but larger programs may need richer enrichment from an SCA platform.
Repository-local JSON waivers are transparent and reviewable, but centralized organizations may prefer a signed policy service. If you centralize, preserve offline reproducibility and make service failure semantics explicit.
You can also enforce NuGetAuditLevel and warning behavior directly in MSBuild. That is simpler for uniform thresholds. A separate policy tool earns its complexity when you need structured reports, fail-closed evidence checks, controlled exceptions, reason codes, and consistent logic across repositories.
Get the complete project
The complete implementation includes:
- the full .NET 10 solution and C# source
- validated policy and waiver configuration
- direct and transitive audit parsing
- JSON and SARIF reports
- Docker support
- ten unit and integration-style policy tests
- a CI workflow and detailed documentation
- synthetic fixtures for deterministic failure testing
Join the Tech Skill Builder Community and get the complete source code and full project:
https://www.elitesolutions.shop/l/TechSkillBuilder
Conclusion
.NET 10 gives us better dependency evidence: transitive auditing by default, package pruning, and machine-readable CLI output. The remaining engineering task is to turn that evidence into a decision developers can understand and security teams can defend.
A credible gate is strict about its own uncertainty. It pins the input contract, distinguishes an empty result from a missing source, keeps exceptions exact and temporary, records every outcome, and returns a meaningful exit code.
That is the difference between collecting warnings and operating a software supply-chain control.
If you found this implementation useful, follow me on Medium for more production-focused .NET, architecture, security, and backend engineering tutorials.