The core insight
Most supply chain attacks against NuGet packages are detected within days of a compromised version being published — by the community, by security researchers, or by automated scanning. This creates an exploitable window, but also a defensible one: a version that has been in the ecosystem for weeks without incident is meaningfully safer than one published yesterday.
This does not mean old packages are safe. It means that recently published versions — including new versions of packages you already trust — deserve additional scrutiny before they land in your project.
What "zero-trust" means here
In this context, zero-trust does not mean trusting nothing. It means:
- No package version enters your project without being reviewed first, regardless of how trusted the publisher is.
- Automation is not a substitute for a review gate — auto-update tools and IDE package managers are convenient but bypass the review window.
- The team follows the same process consistently — one developer using the VS Package Manager to add a package breaks the chain for the whole project.
The tool enforces none of this directly. It provides the visibility. The policy is a team commitment, reinforced by the lock file and CI gate.
The detection window
Supply chain attacks on NuGet packages follow a consistent pattern:
- An attacker publishes a compromised version — through a hijacked account, a typosquatted package ID, or a legitimate-looking new package.
- The version propagates into projects via restore, Dependabot, or template updates.
- The attack is detected — typically within days, sometimes within hours.
The implication: a deliberate delay between a version being published and it being allowed into your project captures most of this window at low cost. A 14-day threshold (the tool default) covers the large majority of detected attacks. A 30-day threshold covers nearly all of them, at the cost of slower dependency updates.
This threshold applies to all packages regardless of trust status. A verified
Microsoft.* package published two days ago carries more risk than one
published six months ago. Account compromise, insider threat, and build pipeline
compromise do not respect the verified flag.
New projects
Stable release SDK templates
dotnet new with a stable release SDK produces a project whose packages
fall into two categories that were both pinned at SDK ship time, weeks or months before
you create the project. They are outside the detection window by definition — a stable
SDK release goes through extensive vetting, and the packages it references have been
in the ecosystem long enough that any compromise would have been detected.
For most teams this means no pre-restore review is needed for SDK templates — audit immediately after the automatic restore instead.
If your organisation requires full coverage before any package reaches the machine,
note that even first-party Microsoft packages can pull in transitive dependencies
owned by private individuals rather than organisations. The audit will surface these,
but by then they are already in your NuGet cache. To review the graph first, suppress
the automatic restore and run preview-restore before allowing anything
to download:
# Optional: review the full graph before any packages reach the machine
dotnet new blazor --no-restore
nuget-audit preview-restore --path .
# Review output — check for transitives owned by private individuals or unknown publishers.
# If satisfied, proceed with restore. If not, remove the project and reconsider your template choice.
dotnet restore
# Standard path:
# 1. Create the project — restores automatically, which is fine for most teams
dotnet new blazor
# 2. Initialize trust config and audit the restored graph
nuget-audit init --path .
nuget-audit audit --path .
# 3. For each flagged package: review and add to TrustConfig.json, then re-run to confirm
nuget-audit trust-owner <owner> --path . # verified publishers you trust broadly
nuget-audit trust-package <id> <ver> --path . # everything else
nuget-audit audit --path .
# 4. Lock the graph — add both properties to Directory.Build.props:
# <Project>
# <PropertyGroup>
# <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
# <RestoreLockedMode>true</RestoreLockedMode>
# </PropertyGroup>
# </Project>
# RestorePackagesWithLockFile generates packages.lock.json for every project.
# RestoreLockedMode enforces it — restore fails if the graph would change.
dotnet restore --force-evaluate
# 5. Add .nuget-audit-ok to .gitignore (machine-local sentinel created by Directory.Build.targets)
# 6. Commit everything
git add packages.lock.json Directory.Build.props Directory.Build.targets .gitignore TrustConfig.json
git commit -m "Lock NuGet graph and add audit enforcement"
Third-party installed templates
Third-party templates (installed via dotnet new install) also restore
automatically during dotnet new. By running dotnet new install
you have already implicitly trusted the template author. The audit's role here is not
to validate that decision; it is to establish TrustConfig.json entries
for the packages the template introduced. Expect more flags on first audit — packages
from publishers not in the default trusted owners list.
Setting up trust
When the audit flags a package, you have two options in TrustConfig.json:
Add the owner to trustedOwners
Trusts all current and future packages from that nuget.org account, regardless of version. Appropriate for publishers you have high confidence in across their entire catalog: well-known organizations, publishers whose packages you use broadly and intend to track as they update.
nuget-audit trust-owner opentelemetry --path .
Add to trustedPackages
Trusts only that package at that exact version. Any version change re-flags for review. This is the more conservative, zero-trust default — even if you trust the publisher, pinning a version means you explicitly review each update before it enters your project. The version-pinning cost is highest exactly where scrutiny should be highest: unverified publishers.
nuget-audit trust-package SomePackage 2.0.0 --path .
Use nuget-audit audit --package-list to generate formatted copy-paste
entries for all flagged packages at once.
Adding and updating packages
With RestoreLockedMode=true in place, dotnet restore fails
if the lock file is out of date — requiring a deliberate
dotnet restore --force-evaluate. That deliberate act is the entry point
for this workflow.
VS Package Manager restores immediately on package selection, bypassing the
preview step. Edit .csproj or Directory.Packages.props
by hand, then run dotnet restore --force-evaluate manually.
# 1. Preview before restore — runs dotnet restore into a temp dir; nothing in your real cache
nuget-audit preview-update SomePackage --version 2.0.0 --path .
# 2. Review the output carefully:
# - Unexpected new packages from unknown publishers?
# - VersionChanged on a package you didn't intend to update?
# - Recently published flag on a package from an unfamiliar owner?
# If anything raises concern, stop here.
# 3. Edit .csproj or Directory.Packages.props by hand
# 4. Restore, forcing the lock file to be re-evaluated
dotnet restore --force-evaluate
# 5. Audit to confirm the restored graph matches the preview
nuget-audit audit --path .
# 6. Update TrustConfig.json for newly flagged packages, re-run to confirm clean
# 7. Commit the updated lock file alongside the package reference change
git add packages.lock.json
git commit -m "Update SomePackage to 2.0.0"
By default, preview-update runs an exact dotnet restore
into a temp directory — the resolved graph matches what would actually be installed.
Step 5 (nuget-audit audit) is still the confirmation gate; preview is
for early warning before lock file changes are committed.
Use --fast for a quicker approximate result if speed matters more than
exact accuracy.
Cloning an existing project
Repo has a lock file
The lock file constrains restore to exactly the versions that were audited when it
was committed. Clone and open normally — the restore cannot silently pull in
different versions. Run nuget-audit audit after cloning to verify the
current state before building.
Repo has no lock file
Clone with the git CLI and do not open in VS yet — opening in VS triggers an
automatic restore, closing the pre-restore review window. The same applies any
time you edit package references: run preview-restore before
dotnet restore to see the full graph first.
# 1. Preview explicit packages and their transitives before anything restores
nuget-audit preview-restore --path .
# 2. Review output — untrusted packages, recently published flag, unrecognised owners
# If anything raises concern, do not proceed.
# 3. Restore
dotnet restore
# 4. Run the full audit to confirm the complete graph
nuget-audit audit --path .
# 5. Update TrustConfig.json, re-run to confirm clean
# 6. Lock the graph and set up VS enforcement (see above)
Pulling a teammate's package change
When a teammate adds or updates a package following this workflow, they commit three
things: the updated package reference, the updated packages.lock.json,
and any TrustConfig.json changes. When you pull:
- Restore runs automatically (VS on solution open, or
dotnet restoreon CLI). WithRestoreLockedMode=trueand the updated lock file, this restore is constrained to exactly the versions the teammate resolved. No unexpected packages can land. - The sentinel goes stale. The pulled
packages.lock.jsonhas a newer timestamp than your local.nuget-audit-ok. The MSBuild target detects this and runs--checkbefore the next build. - If the teammate followed the workflow —
TrustConfig.jsonis updated,--checkpasses, build proceeds. - If the teammate did not update
TrustConfig.json—--checkfails and the build is blocked. CI Gate 2 also fails. The build stays blocked untilTrustConfig.jsonis updated.
The one gap: there is no pre-restore review window on your local
machine — packages download before the audit runs at build time. This is mitigated
by the lock file: what downloads is constrained to exactly the locked versions. The
recently published flag in the --check output will surface any package
within the detection window, giving you the opportunity to raise a concern before
the code is built.
The lock file and RestoreLockedMode
Two properties work together — both belong in Directory.Build.props:
RestorePackagesWithLockFile=true— tells NuGet to generatepackages.lock.jsonfor each project and use it on every restore. Without this, no lock file is created, even ifRestoreLockedModeis set.RestoreLockedMode=true— enforces the lock file: any restore that would change the resolved graph fails rather than silently succeeding.
error NU1004: The lock file is out of date. To update the lock file run the
following command: dotnet restore --force-evaluate
This means editing a .csproj or Directory.Packages.props
and running dotnet restore is not enough — the deliberate
--force-evaluate flag is required. The lock does not prevent package
changes; it prevents unintentional or unreviewed ones.
The lock file is project state and belongs in source control. Every team member and every CI/CD run works from the same frozen graph.
nuget-audit warns if it detects a packages.lock.json
without RestoreLockedMode=true set — setup is self-checking.
Visual Studio pre-build enforcement
Without this, VS may trigger a build immediately after restore — closing the window before anyone acts. A pre-build MSBuild target closes this gap automatically.
nuget-audit init creates Directory.Build.targets at the
solution root. If you skipped init or need to recreate it, run:
nuget-audit init --path .
The target runs nuget-audit audit --check before each build whenever
the lock file has changed, using a sentinel file (.nuget-audit-ok) to
skip builds where the lock file is unchanged. Add .nuget-audit-ok to
.gitignore — it is machine-local state. CI does not use the sentinel;
it always audits unconditionally.
nuget-audit warns — and --check exits 1 — if
RestoreLockedMode=true is set but this target is not present at the
solution root, so incomplete setup does not go unnoticed.
Multi-project solutions
Directory.Build.targets evaluates per project. Each project has its own
packages.lock.json; the sentinel file lives at the solution root. The
first project whose lock file is newer than the sentinel triggers one audit for the
whole solution and updates the sentinel. Subsequent projects in the same build skip it.
Prerequisites
RestoreLockedMode=trueinDirectory.Build.propsnuget-auditinstalled globally on each developer machine
CI/CD gates
Two independent gates together enforce the full workflow:
| Gate | What it catches |
|---|---|
dotnet restore with RestoreLockedMode=true |
Package reference edited without updating the lock file |
nuget-audit audit --check |
Packages needing review, deprecated packages, known vulnerabilities, and setup advisory conditions (missing lock file, RestoreLockedMode, pre-build target, or Package Source Mapping) |
# In your CI pipeline:
dotnet restore # fails with NU1004 if lock file is out of date
nuget-audit audit --check --path . # fails with exit 1 if any packages need review
| Scenario | Gate 1 | Gate 2 |
|---|---|---|
| Package added/updated following the workflow | Passes | Passes |
| Package added/updated, lock file not committed | Fails | Not reached |
| Package added/updated, TrustConfig.json not updated | Passes | Fails |
| No package changes | Passes | Passes — quiet |
Dependabot and automated updates
Dependabot PRs can be merged — or auto-merged — within hours of a compromised version being published, bypassing the detection window entirely. See the Dependabot integration guide for the full mitigation pattern, PR review workflow, and fake PR detection.
Where this policy breaks down
Understanding the gaps is part of the policy:
| Scenario | Gap | Mitigation |
|---|---|---|
| VS NuGet Package Manager | Restore happens before any preview | Team policy: use CLI only for package operations; pre-build sentinel catches it before the next build |
| VS auto-restore on solution open | Triggered by .csproj changes from git pull |
Pre-build sentinel runs audit before build; CI catches it post-restore |
| Git pull with teammate's package change | No pre-restore review window locally | Lock file constrains restore to locked versions; sentinel triggers audit before next build; recently published flag surfaces packages within the detection window |
| Preview accuracy | Preview resolves transitives via the NuGet API and may not exactly match what dotnet restore selects |
Always follow preview with a full audit after restoring to confirm the graph |
| Preview or nightly SDK | Package versions may be within the detection window with less vetting than a stable release | Not handled — treat the resulting project as an unvetted dependency graph and audit manually, or accept the additional risk |
| Private-feed packages in preview | Supported when credentials are available via NuGet.Config or Azure Artifacts Credential Provider; --version required |
Configure NuGet.Config credentials or install the Azure Artifacts Credential Provider |