Supply Chain and Scanning
Scanning packages and images for known vulnerabilities, producing a bill of materials, and giving the pipeline least-privilege credentials.
dotnet package list --vulnerable --include-transitiveYour supply chain is everything that ends up in the shipped image but was not written by you: the NuGet packages you reference, the packages those depend on, the base image's operating system, and the credentials the pipeline itself holds. Most vulnerabilities reported against an application arrive through one of them.
Packages
NuGet audit runs during restore and compares your packages against published advisories. NuGetAudit is true by default. Each finding becomes a warning chosen by severity: NU1901 low, NU1902 moderate, NU1903 high, NU1904 critical. NuGetAuditMode sets the scope, direct for packages you reference yourself and all for their dependencies too. For a project targeting net10.0 or later the default is all; below that it is direct.
To list findings without building, run dotnet package list --vulnerable --include-transitive. In .NET 9 and earlier the same command is spelled dotnet list package --vulnerable --include-transitive. .NET 10 moved the noun first and kept both spellings working, so you will meet either one in other people's scripts.
Images and bills of materials
Packages are half the problem; the base image carries an operating system with its own advisories. Scan the pushed image with a tool such as Trivy or Grype and fail the job on high and critical findings. Then produce a software bill of materials, or SBOM: a machine-readable list of everything inside the artifact, in SPDX or CycloneDX format. Keep it with the release so that when an advisory appears next year you can answer "were we shipping that?" without rebuilding.
Credentials
Give each job only the permissions it needs, and prefer a short-lived token from OpenID Connect over a stored key that never expires.
Example
using System.Text.Json;
// A trimmed CycloneDX bill of materials. A real one lists every package,
// direct and transitive, at the exact version that was restored.
string sbom = """
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"components": [
{ "type": "library", "name": "Npgsql", "version": "9.0.3" },
{ "type": "library", "name": "Serilog", "version": "4.2.0" },
{ "type": "library", "name": "Dapper", "version": "2.1.66" }
]
}
""";
using JsonDocument document = JsonDocument.Parse(sbom);
JsonElement components = document.RootElement.GetProperty("components");
List<string> lines = [];
foreach (JsonElement component in components.EnumerateArray())
{
string name = component.GetProperty("name").GetString()!;
string version = component.GetProperty("version").GetString()!;
lines.Add($"{name} {version}");
}
// Sorting keeps the report comparable between two releases.
lines.Sort(StringComparer.Ordinal);
foreach (string line in lines)
{
Console.WriteLine(line);
}
// Output:
// Dapper 2.1.66
// Npgsql 9.0.3
// Serilog 4.2.0When to use it
- Fail a build when a transitive package gains a high-severity advisory.
- Produce a bill of materials for every released image that an auditor can read.
- Replace a long-lived cloud access key in CI with a short-lived OIDC token.
- Answer which releases contained a given package version without rebuilding them.
More examples
Audit settings for the whole repository
Splitting severities is what makes the gate survivable: the build stops for the findings that matter and merely nags about the rest. Reach for this on day one of a repository, because adding it later with TreatWarningsAsErrors already on tends to break every open branch at once. Use dotnet nuget why to find which direct package pulled in an offending transitive one.
<!-- Directory.Build.props at the repository root. -->
<Project>
<PropertyGroup>
<!-- On by default. Written out so nobody has to wonder. -->
<NuGetAudit>true</NuGetAudit>
<!-- Include dependencies of dependencies. Already the default for
projects targeting net10.0 or later. -->
<NuGetAuditMode>all</NuGetAuditMode>
<!-- Report everything, then choose below what actually breaks a build. -->
<NuGetAuditLevel>low</NuGetAuditLevel>
<!-- High and critical advisories fail the build. Low and moderate stay
warnings, so a new advisory cannot block an unrelated hotfix. -->
<WarningsAsErrors>$(WarningsAsErrors);NU1903;NU1904</WarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<!-- Last resort, for an advisory you have read and judged not to apply.
It silences that advisory for every package in the solution. -->
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-0000-0000-0000" />
</ItemGroup>
</Project>Scanning the image and keeping the SBOM
The scan runs against the pushed image, so it sees the base operating system as well as your own files, which a restore-time audit never does. --exit-code 1 is what turns a report into a gate. Keep the SBOM as a release artifact; on GitHub you can also sign it and the build provenance with actions/attest-build-provenance, which records who built the image and from which commit.
jobs:
scan:
runs-on: ubuntu-24.04
needs: image
# Only what this job needs. id-token lets it exchange a short-lived
# OIDC token for cloud access instead of holding a stored key.
permissions:
contents: read
id-token: write
steps:
# Trivy and Syft are not preinstalled on the runner. Install them, or
# use aquasecurity/trivy-action and anchore/sbom-action.
- name: Scan the image
run: |
trivy image --exit-code 1 --severity HIGH,CRITICAL \
ghcr.io/contoso/orders-api:${{ github.sha }}
- name: Build the bill of materials
run: |
syft scan ghcr.io/contoso/orders-api:${{ github.sha }} \
-o spdx-json=sbom.spdx.json
- uses: actions/upload-artifact@v7
with:
name: sbom
path: sbom.spdx.jsonAuditing in one pipeline without breaking the others
The scheduled audit workflow runs dotnet restore -p:AuditPipeline=true and fails loudly; the normal build does not. Use this when a team has been burned by an advisory appearing mid-release. NU1900 and NU1905 are worth keeping in the list too: they mean the audit could not reach its data source, which otherwise looks exactly like a clean result.
<!-- Directory.Build.props. A new advisory published this morning should
not stop a bug fix going out this afternoon. -->
<Project>
<PropertyGroup>
<NuGetAuditCodes>NU1900;NU1901;NU1902;NU1903;NU1904;NU1905</NuGetAuditCodes>
<!-- In the dedicated audit run, audit findings are errors. -->
<WarningsAsErrors Condition=" '$(AuditPipeline)' == 'true' ">$(WarningsAsErrors);$(NuGetAuditCodes)</WarningsAsErrors>
<!-- Everywhere else they stay warnings, even under TreatWarningsAsErrors. -->
<WarningsNotAsErrors Condition=" '$(AuditPipeline)' != 'true' ">$(WarningsNotAsErrors);$(NuGetAuditCodes)</WarningsNotAsErrors>
</PropertyGroup>
</Project>
Discussion