OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit and SRI
Why is A03:2025 Software Supply Chain Failures its own category? Lessons from event-stream, Log4Shell, xz, polyfill.io and the 2025 npm incidents; NuGetAudit scanning transitive dependencies on net10.0, warnings that break the build, lock files, npm audit and npm ci, SRI for CDN scripts, an SBOM with CycloneDX, provenance and Dependabot.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
The Bookstore API's .csproj has only two packages, and you picked both versions yourself. When you build the project, how many packages will NuGet warn you about: zero, one, or also packages you have never heard of? And when the warning appears, does the build break?
We will see the answer in a real restore output shortly. A hint: the number of packages you add to a project is not the number of packages that run in it.
Why Is the Supply Chain Its Own Category?
A03:2025 Software Supply Chain Failures is the expanded version of A06 Vulnerable and Outdated Components from the 2021 list. The new name looks at the whole chain: packages, their dependencies, the package source, your build machine, your CI/CD pipeline and distribution. Few CWEs map to the category (6), but it is one of the two categories in the 2025 list chosen by the community survey: small in the data, big in the field.
Think of it this way: a restaurant kitchen can be spotless, but if the supplier delivers spoiled vegetables, the dish is still spoiled. In most projects the code you write is a small part of the total; the rest comes from NuGet and npm and runs with your application's permissions.
This post is entirely on the defensive side, but the series rule applies: only try these techniques on systems you own or have written permission to test.
Lessons from the Incidents
Each incident shows a different link breaking. In the event-stream incident on npm, maintenance of the package was handed to a new volunteer maintainer. The new maintainer added a malicious dependency called flatmap-stream, and that code targeted a specific bitcoin wallet application (Copay). Lesson: how trustworthy a package is depends on who runs it today.
Log4Shell (CVE-2021-44228) in December 2021 was a vulnerability that allowed remote code execution through the JNDI feature of Java's Log4j 2 library. It had a CVSS score of 10.0, affected versions 2.0-beta9 through 2.14.1, and the first fix came with 2.15.0, followed by 2.16 and 2.17. OWASP's A03:2025 page uses it as an example too. Lesson: most teams used Log4j as a dependency of another package and did not know it was there.
The xz-utils backdoor (CVE-2024-3094), discovered in March 2024, was planted in versions 5.6.0 and 5.6.1 of xz/liblzma by a maintainer identity called "Jia Tan" that had spent years earning trust. Andres Freund noticed it while chasing an unexpected delay in sshd. Lesson: malicious code can hide inside the build process rather than in the source code.
In the polyfill.io incident, the domain and the GitHub repo were sold to a company called Funnull in February 2024. According to the Sansec report published on 25 June 2024, the CDN started serving malicious redirect code to the many sites that included the script directly on their pages. Lesson: a <script> from another domain gives the owner of that domain the right to run code on your page.
2025 was a busy year for npm. On 8 September 2025, a browser-side crypto stealer was added to 18 packages (chalk, debug, ansi-styles, supports-color and others) of a maintainer whose account was taken over through phishing. On 15–16 September, the first self-propagating npm worm, known as Shai-Hulud, spread through popular packages such as @ctrl/tinycolor: it stole npm and GitHub tokens and republished the victim's other packages. The OWASP A03:2025 page says more than 500 package versions were affected. On 24 November 2025 a second wave (Shai-Hulud 2.0) arrived, this time through a preinstall script. In response, on 9 December 2025 GitHub permanently revoked classic npm tokens.
The NuGet Side: NuGetAudit
Let's set up a small Bookstore API project for the lab. Imagine it was migrated from old code: there is an old Newtonsoft.Json version for JSON and an old SQL Server driver for reporting.
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.5" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
</ItemGroup>
</Project>
using Newtonsoft.Json;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/books/legacy", () =>
{
var books = new[] { new { Id = 1, Title = "Clean Code" } };
return Results.Content(JsonConvert.SerializeObject(books), "application/json");
});
app.Run();
Running dotnet build prints this during restore (paths shortened, repeated lines removed):
.../Bookstore.Api.csproj : warning NU1902: Package 'Azure.Identity' 1.10.3 has a known moderate severity vulnerability, https://github.com/advisories/GHSA-m5vv-6r4h-3vj9
.../Bookstore.Api.csproj : warning NU1902: Package 'Azure.Identity' 1.10.3 has a known moderate severity vulnerability, https://github.com/advisories/GHSA-wvxc-855f-jvrv
.../Bookstore.Api.csproj : warning NU1902: Package 'Microsoft.Identity.Client' 4.56.0 has a known moderate severity vulnerability, https://github.com/advisories/GHSA-m5vv-6r4h-3vj9
.../Bookstore.Api.csproj : warning NU1901: Package 'Microsoft.Identity.Client' 4.56.0 has a known low severity vulnerability, https://github.com/advisories/GHSA-x674-v45j-fwxw
.../Bookstore.Api.csproj : warning NU1903: Package 'Newtonsoft.Json' 12.0.1 has a known high severity vulnerability, https://github.com/advisories/GHSA-5crp-9r3c-p9vr
...
Build succeeded.
10 Warning(s)
0 Error(s)
Here is the answer to the opening question. We added two packages, but warnings came for three, and two of them (Azure.Identity and Microsoft.Identity.Client) are not in the .csproj at all. SqlClient 5.1.5 brings them in; we call these transitive dependencies. The warnings come from NuGetAudit: on by default since the .NET 8 SDK, it checks the GitHub Advisory Database during restore. The codes tell you the severity: NU1901 low, NU1902 moderate, NU1903 high, NU1904 critical.
Scanning transitive packages too is a change that came with .NET 10. If the project targets net10.0 or later, the default for NuGetAuditMode is all; for older targets it is direct. When I restored the same project with -p:NuGetAuditMode=direct, the Azure.Identity warnings disappeared; the vulnerability was still there, it just was no longer checked.
Asking from the command line
dotnet package list --vulnerable
Project `Bookstore.Api` has the following vulnerable packages
[net10.0]:
Top-level Package Requested Resolved Severity Advisory URL
> Newtonsoft.Json 12.0.1 12.0.1 High https://github.com/advisories/GHSA-5crp-9r3c-p9vr
This command is the noun-first form in .NET 10; the old dotnet list package --vulnerable still works. Note that the transitive packages are missing from the output. Unlike restore, the command-line tool shows them only when you ask:
dotnet package list --vulnerable --include-transitive
Top-level Package Requested Resolved Severity Advisory URL
> Newtonsoft.Json 12.0.1 12.0.1 High https://github.com/advisories/GHSA-5crp-9r3c-p9vr
Transitive Package Resolved Severity Advisory URL
> Azure.Identity 1.10.3 Moderate https://github.com/advisories/GHSA-wvxc-855f-jvrv
Moderate https://github.com/advisories/GHSA-m5vv-6r4h-3vj9
> Microsoft.Identity.Client 4.56.0 Low https://github.com/advisories/GHSA-x674-v45j-fwxw
Moderate https://github.com/advisories/GHSA-m5vv-6r4h-3vj9
To fix it, I first moved Newtonsoft.Json to 13.0.4 and SqlClient to its latest version, 7.1.1. Here came an instructive surprise: the current SqlClient now brought NU1902 and NU1903 warnings for System.Security.Cryptography.Pkcs 9.0.18. Moving to the newest version is not always enough; you may need to pin the transitive package to a patched version with a direct reference.
Breaking the build and the lock file
A warning nobody reads is useless. The final version looks like this:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<WarningsAsErrors>$(WarningsAsErrors);NU1902;NU1903;NU1904</WarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.1.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
</ItemGroup>
</Project>
NuGetAuditMode matches the default, but writing it explicitly documents the intent. WarningsAsErrors turns warnings into errors. With the old .csproj and -p:WarningsAsErrors="NU1903%3BNU1904", the build broke like this:
.../Bookstore.Api.csproj : error NU1903: Warning As Error: Package 'Newtonsoft.Json' 12.0.1 has a known high severity vulnerability, https://github.com/advisories/GHSA-5crp-9r3c-p9vr
Build FAILED.
With the new version it is 0 Warning(s), 0 Error(s). RestorePackagesWithLockFile generates a packages.lock.json for the project; it holds the resolved version and the contentHash of every package. If you use dotnet restore --locked-mode in CI, a change that does not match the lock file stops the restore. When I manually changed Newtonsoft.Json to 13.0.3 in the .csproj:
.../Bookstore.Api.csproj : error NU1004: The package reference Newtonsoft.Json version has changed from [13.0.4, ) to [13.0.3, ).The packages lock file is inconsistent with the project dependencies so restore can't be run in locked mode. Disable the RestoreLockedMode MSBuild property or pass an explicit --force-evaluate option to run restore to update the lock file.
The npm Side: audit, ci and the Lock File
Every project with an Angular or plain JavaScript front end asks the same question on npm. In the lab I created a bookstore-web folder with a single dependency and installed an old lodash:
npm install lodash@4.17.20 --save-exact --ignore-scripts
npm audit
# npm audit report
lodash <=4.17.23
Severity: high
Command Injection in lodash - https://github.com/advisories/GHSA-35jh-r3h4-6jhm
Regular Expression Denial of Service (ReDoS) in lodash - https://github.com/advisories/GHSA-29mw-wpgm-hmr9
lodash vulnerable to Code Injection via `_.template` imports key names - https://github.com/advisories/GHSA-r5fr-rjxr-66jc
Lodash has Prototype Pollution Vulnerability in `_.unset`, `_.omit`, and `_.pullAt` functions - https://github.com/advisories/GHSA-xxjr-mmjv-4gpg
fix available via `npm audit fix --force`
Will install lodash@4.18.1, which is outside the stated dependency range
node_modules/lodash
1 high severity vulnerability
In this case npm audit returned exit code 1, so it can be used as a CI step. Because I pinned the exact version (--save-exact), the suggested fix was marked "outside the stated dependency range"; instead of --force I raised the version myself and did a clean install from the lock file:
npm install lodash@4.18.1 --save-exact --ignore-scripts
rm -rf node_modules
npm ci --ignore-scripts
npm audit signatures
added 1 package, and audited 2 packages in 447ms
found 0 vulnerabilities
audited 1 package in 1s
1 package has a verified registry signature
Unlike npm install, npm ci follows package-lock.json to the letter and fails if the lock file contradicts package.json. The integrity field in the lock file carries the SHA-512 hash of each package. --ignore-scripts stops preinstall/postinstall scripts, the kind Shai-Hulud 2.0 used, from running during installation.
SRI for CDN Scripts
Let's apply the polyfill.io lesson in code. Subresource Integrity (SRI) is the way to tell the browser "this file's hash must be this". In the lab I ran the same ASP.NET Core application on two ports; think of 5122 as the CDN. A small static file setting that adds an Access-Control-Allow-Origin header to the CDN response is enough:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
ctx.Context.Response.Headers.AccessControlAllowOrigin = "*"
});
app.Run();
openssl dgst -sha384 -binary wwwroot/lib/banner.js | openssl base64 -A
<!doctype html>
<html>
<head>
<script src="http://localhost:5122/lib/banner.js"
integrity="sha384-GCEqfNgEwLGIFKMv19TiDwBlWv+l2zxA1qBDKTFMDYVMpGpKF/3oGcqfVN+2Jf2W"
crossorigin="anonymous"></script>
</head>
<body>Bookstore</body>
</html>
When I opened the page with headless Chrome, the script ran and wrote "banner.js loaded" to the console. Then I added a single line to the end of the file "on the CDN", acting as if the CDN had been compromised. This time the same page printed the following and the script never ran:
[...:INFO:CONSOLE:0] "Failed to find a valid digest in the 'integrity' attribute for resource 'http://localhost:5122/lib/banner.js' with computed SHA-384 integrity 'lUw3wA8+QucR+fBeRH3IxuYTMNQe0LUUI66Rb16mWUqwZzletQ9jU+fmEn3V0VYm'. The resource has been blocked.", source: http://localhost:5121/index.html (0)
Know SRI's limit as well: it only works for files whose content never changes and whose version is pinned. You cannot put SRI on a service like polyfill.io that generates different content depending on the requesting browser; the right answer there is to serve the file from your own server.
SBOM, Provenance and Dependabot
On Log4Shell day the most common question was "do we use this?". An SBOM (Software Bill of Materials) is a list of every component inside your application, so you can answer that question in seconds. OWASP's A03 page recommends generating and managing an SBOM that covers direct and transitive dependencies. There are two common formats: OWASP's CycloneDX and the Linux Foundation's SPDX. In .NET I installed the CycloneDX tool as a local tool and ran it:
dotnet new tool-manifest
dotnet tool install CycloneDX
dotnet dotnet-CycloneDX Bookstore.Api.csproj -o sbom -F Json
Found 17 packages
Creating CycloneDX BOM
Writing to: .../Bookstore.Api/sbom/bom.json
{
"type": "library",
"bom-ref": "pkg:nuget/Newtonsoft.Json@13.0.4",
"name": "Newtonsoft.Json",
"version": "13.0.4",
"scope": "required",
"hashes": [
{
"alg": "SHA-512",
"content": "6D1FAFF84FF227A83B195DAE5F0D8EAD44A36187E32E438B0BC243E24092DB79AC2DAA672AD7493C1240EF97F01C7FBE12B21F7DE22ACD82132F102EAF82805C"
}
],
"purl": "pkg:nuget/Newtonsoft.Json@13.0.4",
...
}
We added three packages; the SBOM found 17. The purl (package URL) field identifies the component in a way that is portable between tools. If you keep the SBOM with every release, the answer is ready at the next big CVE.
If you publish packages, know two concepts. npm provenance proves which repo and which CI job built the package, with a record signed through Sigstore. Trusted publishing uses a short-lived, job-specific CI identity instead of a long-lived npm token; there is no token to steal. Provenance tells you where the package was built, not that the code is harmless.
The last piece is to stop doing all this by hand. On GitHub, Dependabot shows alerts for vulnerable dependencies and can open pull requests for security updates and regular version updates. We talked about repo settings in the Git & GitHub series; here we add a single file:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "nuget"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/bookstore-web"
schedule:
interval: "weekly"
Common Pitfalls
The first is looking only at direct packages. In the lab most warnings came from packages we never added, and without --include-transitive the command line said "no vulnerable packages". In projects that target something older than net10.0, restore also checks only direct packages by default.
The second is silencing the warning. It is easy to turn off NU190x with NoWarn or to ignore the npm audit output. If there is an advisory that really doesn't affect you, suppress only that advisory URL with NuGetAuditSuppress and write the reason in a comment.
The third is running npm audit fix --force blindly. This command can jump major versions and break the application. Make the update a pull request and run your tests.
The fourth is putting integrity on a cross-origin script and forgetting the crossorigin attribute. I tried this in the lab too; Chrome blocked the script even though the file was correct and said "integrity cannot be enforced", because without CORS it cannot verify the content.
Checklist
On the NuGet side, target net10.0 or set NuGetAuditMode to all, turn moderate and above into errors with WarningsAsErrors, generate packages.lock.json and use --locked-mode in CI. Make running dotnet package list --vulnerable --include-transitive a habit.
On the npm side, commit the lock file, run npm ci --ignore-scripts and npm audit in CI, and verify registry signatures with npm audit signatures. If you publish packages, use trusted publishing and provenance.
On the browser side, serve third-party scripts from your own server where possible; if a CDN is a must, pin the version and add integrity and crossorigin. Generate and keep an SBOM with every release, and keep Dependabot or a similar tool switched on.
A Small Experiment
★☆☆ In one of your own .NET projects, run dotnet package list --vulnerable and dotnet package list --vulnerable --include-transitive separately. Note the difference between the two outputs.
★★☆ Add RestorePackagesWithLockFile to the lab project, look at packages.lock.json, then change a package version by hand and see the NU1004 error yourself with dotnet restore --locked-mode.
★★★ Build the SRI lab: generate a script's SHA-384 hash, add it to the page with integrity, change the file and see it blocked in the headless Chrome console. Then remove the crossorigin attribute and see what changes.
In the next post we move into the A04:2025 Cryptographic Failures category: we will see the difference between encoding, hashing and encryption in code, compare Random with RandomNumberGenerator, and look at the ASP.NET Core defaults for HTTPS and HSTS.