Safeguard
Open Source Security

How Snyk scans NuGet and .NET project files for vulnerabl...

How Snyk resolves NuGet and .NET dependency graphs from csproj, packages.config, and project.assets.json files to find vulnerable packages.

Aman Khan
AppSec Engineer
7 min read

A .NET solution with 40 project files, a mix of legacy packages.config entries and modern PackageReference items, and no lockfile checked in is a common state for enterprise codebases -- and it is exactly the case that breaks naive dependency scanners. Snyk built its NuGet support around a fact many teams overlook: .csproj and packages.config files are declarations of intent, not resolved dependency graphs. A <PackageReference Include="Newtonsoft.Json" Version="12.0.1" /> line does not tell you what transitive packages NuGet actually pulled in, at what resolved versions, for which target framework. To answer that question accurately, Snyk leans on the same resolution machinery .NET itself uses -- the SDK and the NuGet client -- rather than trying to reimplement NuGet's version-resolution algorithm from scratch. Here is how that pipeline actually works, file by file.

What files does Snyk actually read in a .NET project?

Snyk's NuGet plugin (open-sourced at snyk/snyk-nuget-plugin) parses three manifest formats: project.json (the pre-2017 legacy format), packages.config (classic .NET Framework projects), and project.assets.json (the modern restore output). It also recognizes .sln, .csproj, .vbproj, and .fsproj files as entry points, plus Paket's paket.dependencies and paket.lock. Snyk's documentation lists support spanning .NET 6 through .NET 10, with .NET 8 and later supporting the full range of current SDK versions, plus .NET Framework 4.x projects that have been converted to the SDK-style project format and .NET Standard targets across the board. The plugin's job in every case is the same: turn whatever manifest format it finds into a normalized dependency graph -- a tree of direct and transitive packages with resolved versions -- that Snyk's core scanning engine can compare against its vulnerability database. Which of those three manifest types gets parsed, and how faithfully, depends heavily on whether the project uses the old or new .NET tooling model, which is where the split described below matters.

Why does Snyk need dotnet restore before it can scan a modern project?

Because for SDK-style PackageReference projects, Snyk does not resolve the dependency tree itself -- it reads the one the .NET SDK already resolved. When you run dotnet restore, the SDK writes obj/project.assets.json, a lockfile-like artifact that lists every direct and transitive package, its exact resolved version per target framework, and how it was pulled in. Snyk's CLI docs state this directly: "You must run dotnet restore before snyk test to restore dependencies and ensure accurate scan results." Skip that step and snyk test either fails to find a graph or reports one built from partial information. This is also why Snyk's accuracy on modern projects is described in its own documentation as coming from using "the .NET SDK directly to resolve dependencies" -- it inherits whatever the SDK resolved, including features like Central Package Management via Directory.Packages.props, Directory.Build.props overrides, and global.json SDK pinning, because those all feed into the same restore step before Snyk ever looks at the output.

What happens with older packages.config projects that have no assets.json?

For those, Snyk falls back to static analysis because there is no SDK-generated lockfile to read. Classic .NET Framework projects using packages.config don't produce a project.assets.json -- NuGet's older tooling installs packages into a packages/ folder on disk instead. Snyk's documented workaround is to run nuget install -OutputDirectory packages first, populating that folder, then run snyk test, which scans the resulting packages directory. Where no restore has happened at all -- common in CI environments scanning a repo without a build step, or in SCM integrations that only see source, not a built workspace -- Snyk instead approximates the graph through static analysis, applying NuGet's own documented version-resolution algorithm (nearest-wins, lowest applicable version rules) to the version ranges declared in the manifest. Snyk's docs are explicit that this is an approximation, not a guarantee of matching what a real restore would produce, because things like custom NuGet feeds, floating versions, and package source mapping can change the outcome in ways static analysis can't fully replicate.

Why does scanning behave differently in SCM integrations than in the CLI?

Because an SCM integration (scanning a GitHub, GitLab, Bitbucket, or Azure Repos import) never gets to run dotnet restore or nuget install -- it only has read access to the repository's file tree, not a build environment. Snyk's own guidance confirms that in this mode, scanning "follows the NuGet dependency resolution algorithm to construct a dependency tree" rather than reading an actual restore artifact. That means SCM-imported .NET projects are scanned via the same static-approximation path used for un-restored packages.config projects, even if the project itself uses modern PackageReference syntax. This is a meaningful distinction for teams deciding where to run scans: CLI scans inside a CI pipeline, run after a real dotnet restore, get the SDK-accurate graph; SCM-only scans of the same repository get an approximated one. It also explains a specific, documented limitation: Snyk cannot open a fix pull request for a PackageReference entry that omits a version attribute, because there's no version string for the resolver to reason about or bump.

How does Snyk decide a resolved package version is actually vulnerable?

Once it has a dependency tree -- whether SDK-resolved or statically approximated -- Snyk walks every direct and transitive node and checks its exact version against Snyk's vulnerability database, which aggregates NuGet advisory data (sourced in part from the NuGet Gallery / NuGet API v3 feed) alongside Snyk's own research team's findings. Because NuGet allows the same logical dependency to resolve to different versions across target frameworks in a multi-targeted project (say, net6.0 vs net8.0 in the same .csproj), Snyk evaluates each target framework's resolved set separately rather than treating the project as having one flat dependency list. A package flagged vulnerable at 12.0.1 but fixed at 12.0.3 shows up as a finding only for the target framework(s) where restore actually resolved it to the vulnerable version -- which is one of the reasons an accurate, SDK-driven restore matters more here than in ecosystems with a single universal lockfile format.

What limitations should teams know about before relying on the results?

The two most consequential are version-attribute requirements and dev-dependency handling. Snyk's own docs state plainly that it "does not support PackageReference entries without a version attribute" -- if a .csproj line reads <PackageReference Include="Serilog" /> with the version supplied elsewhere (a central Directory.Packages.props file not being picked up, or an MSBuild variable Snyk can't resolve), Snyk can flag the package as unversioned but cannot open an automated fix pull request for it, since there's no explicit version string for the resolver to bump. Separately, for SCM-based imports, build- and development-only dependencies are excluded from scans by default and have to be re-enabled through project settings, which means a vulnerable analyzer or test-only package can go unreported unless a team knows to turn that option on. Snyk also does not support package-lock.json-style parity for NuGet the way it does for npm, and Paket-managed projects can be scanned via CLI but aren't picked up by the SCM import flow at all -- worth knowing if a team standardizes on Paket for deterministic restores.

How Safeguard Helps

Understanding a scanner's mechanics matters because the accuracy gaps described above are structural, not incidental -- they show up in real .NET estates with mixed legacy and modern projects, multi-targeted libraries, and CI pipelines where restore steps get skipped or cached in ways that go stale. Safeguard's approach to software supply chain security starts from the same principle this piece has been making: a dependency scan is only as trustworthy as the resolved graph underneath it. Safeguard continuously verifies that dependency manifests, lockfiles, and build provenance stay consistent across your .NET services, so a PackageReference version bump, a restored package that silently pulled in a different transitive version, or a repo that's being scanned without its build context doesn't create a blind spot. For teams running NuGet and .NET across dozens of services, that means visibility that doesn't depend on remembering to restore before every scan, and supply chain risk data that reflects what's actually shipped, not just what a manifest file says was intended.

Never miss an update

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