
Learn how to build a DevSecOps CI/CD pipeline for .NET in 2026 — secret scanning, SAST, SBOMs and runtime hardening. Start securing your builds today.
Building a DevSecOps CI/CD pipeline is no longer a "nice to have" for .NET teams — in 2026 it is the baseline expectation of every enterprise security review, every SOC 2 auditor, and increasingly every regulator. The EU Cyber Resilience Act obligations are landing, SLSA provenance is showing up in procurement checklists, and the majority of breaches that hit .NET shops still trace back to the same three things: a leaked credential, an unpatched transitive NuGet package, and a SQL string that someone concatenated at 4:45pm on a Friday.
The good news is that the .NET toolchain has quietly become one of the best-instrumented ecosystems for security automation. Roslyn analyzers run inside your compiler. dotnet list package --vulnerable ships in the SDK. SBOM generation is a one-line MSBuild target. This guide walks through how to build security into your pipeline from day one, with runnable C# examples and the reasoning behind each control.
What DevSecOps Actually Means (and What It Doesn't)
DevSecOps is often reduced to "run a scanner in CI." That framing is why so many programs fail. The real definition is simpler: security controls execute automatically at the earliest point where they can still be cheap to fix.
That last clause matters. A SQL injection caught by a Roslyn analyzer in your IDE costs about thirty seconds to fix. The same bug caught by a penetration test six months later costs a sprint, a customer notification, and a retrospective. This is the entire economic argument for shift left security — not that early scanning finds more bugs, but that early bugs are 10–100x cheaper to remediate.
What DevSecOps is not: a gate that blocks every build on every CVE. If your pipeline cries wolf, developers will learn to bypass it. A pipeline that blocks on 12 findings and gets respected is worth more than one that reports 400 findings and gets ignored.
The Five Stages of a Secure .NET Pipeline
Think of your pipeline as five checkpoints, each catching a different class of problem:
- Pre-commit — secret detection, formatting, fast analyzers
- Build — Roslyn security analyzers, banned API enforcement, warnings as errors
- Test — dependency scanning, SAST, security-focused unit tests
- Package — SBOM generation, container scanning, artifact signing
- Deploy & runtime — secretless auth, least privilege, runtime monitoring
Stage 1: Stop Secrets Before They Reach Git
Once a credential is in git history, it is compromised — rewriting history does not help because forks, CI caches, and clones already have it. Rotation is the only remedy, so prevention pays enormous dividends.
Install a pre-commit hook using Gitleaks or the built-in dotnet-gitleaks tooling, and pair it with a build-time analyzer. But the structural fix is architectural: make it impossible for a secret to need to live in source. Here is the pattern to standardise on:
// DON'T: a connection string with credentials in appsettings.json
// "ConnectionStrings": { "Db": "Server=prod;User Id=sa;Password=Hunter2;" }
// DO: passwordless auth via managed identity — there is no secret to leak
using Azure.Identity;
using Microsoft.Data.SqlClient;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped(_ =>
{
// Connection string contains NO credentials
var cs = builder.Configuration.GetConnectionString("Db");
// e.g. "Server=prod.database.windows.net;Database=orders;
// Authentication=Active Directory Default;"
return new SqlConnection(cs);
});
// Pull any remaining secrets from a vault at startup, never from source control
builder.Configuration.AddAzureKeyVault(
new Uri("https://my-vault.vault.azure.net/"),
new DefaultAzureCredential());
Why this works: DefaultAzureCredential resolves to your developer identity locally and to a workload/managed identity in production. The credential is issued at runtime, short-lived, and never serialised into a file an engineer could commit. You have removed the class of bug rather than detecting instances of it.
Stage 2: Turn the Compiler Into a Security Scanner
Most .NET teams leave free security tooling switched off. The .NET SDK ships security analyzers (the CA2100–CA5405 range) that are disabled or informational by default. Enable them at the highest level your codebase can tolerate, then ratchet up.
<!-- Directory.Build.props — applies to every project in the repo -->
<Project>
<PropertyGroup>
<AnalysisMode>All</AnalysisMode>
<AnalysisLevel>latest</AnalysisLevel>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<Nullable>enable</Nullable>
<!-- The line that makes security real: findings break the build -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Supply-chain hardening -->
<RestoreLockedMode>true</RestoreLockedMode>
<NuGetAuditMode>all</NuGetAuditMode>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
</PropertyGroup>
</Project>
NuGetAuditMode=all is the important one for 2026. It audits transitive dependencies, not just direct ones — and transitive packages are where supply-chain attacks live. RestoreLockedMode forces restore to fail if packages.lock.json doesn't match, which defeats dependency-confusion attacks where an attacker publishes a higher version number of your internal package name to nuget.org.
Next, ban the dangerous APIs outright. Create a BannedSymbols.txt file and reference it as an AdditionalFiles item:
// BannedSymbols.txt
// T:System.Runtime.Serialization.Formatters.Binary.BinaryFormatter;
// Insecure deserialization — use System.Text.Json
// M:System.Security.Cryptography.MD5.Create();
// Broken hash — use SHA256
// M:System.Text.Encoding.UTF8.GetString(System.Byte[]);
// Validate encoding explicitly
// Now this fails the build with RS0030 instead of shipping:
var hash = MD5.Create().ComputeHash(passwordBytes); // ERROR
// The compliant version — cost-factor password hashing
using System.Security.Cryptography;
public static string HashPassword(string password)
{
byte[] salt = RandomNumberGenerator.GetBytes(16);
using var pbkdf2 = new Rfc2898DeriveBytes(
password, salt, iterations: 600_000, HashAlgorithmName.SHA256);
byte[] key = pbkdf2.GetBytes(32);
return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(key)}";
}
public static bool Verify(string password, string stored)
{
var parts = stored.Split('.');
byte[] salt = Convert.FromBase64String(parts[0]);
byte[] expected = Convert.FromBase64String(parts[1]);
using var pbkdf2 = new Rfc2898DeriveBytes(
password, salt, 600_000, HashAlgorithmName.SHA256);
// Constant-time compare — prevents timing attacks
return CryptographicOperations.FixedTimeEquals(
pbkdf2.GetBytes(32), expected);
}
Why FixedTimeEquals matters: a naive SequenceEqual returns as soon as bytes differ. An attacker measuring response times can recover a hash byte-by-byte. This is the kind of subtlety no scanner will catch for you — which is why secure coding in C# training sits alongside tooling, not behind it.
Stage 3: Security Unit Tests — The Underused Control
SAST tools have false negatives. Tests do not. Write executable assertions for your security invariants, and they will run on every commit forever:
public class AuthorizationTests
{
[Fact]
public async Task Every_endpoint_requires_authorization_unless_opted_out()
{
var endpoints = _factory.Services
.GetRequiredService<EndpointDataSource>()
.Endpoints
.OfType<RouteEndpoint>();
var unprotected = endpoints.Where(e =>
e.Metadata.GetMetadata<IAuthorizeData>() is null &&
e.Metadata.GetMetadata<IAllowAnonymous>() is null)
.Select(e => e.RoutePattern.RawText);
Assert.Empty(unprotected); // Fail loudly on a forgotten [Authorize]
}
[Theory]
[InlineData("' OR 1=1--")]
[InlineData("'; DROP TABLE Orders;--")]
public async Task Search_is_not_injectable(string payload)
{
var response = await _client.GetAsync($"/orders?q={Uri.EscapeDataString(payload)}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Empty(await ReadOrders(response));
}
}
The first test is the single highest-value security test most ASP.NET Core teams are missing. Forgotten [Authorize] attributes on new controllers are a leading cause of broken access control — number one on the OWASP Top 10 — and this catches every one of them at build time.
Stage 4: SBOMs and Supply-Chain Provenance
A software bill of materials (SBOM) is now contractually required by many US federal and UK public-sector buyers. More practically, it answers the question you will be asked the morning after the next Log4Shell-class event: "are we affected?" Without an SBOM, that takes days. With one, it takes a grep.
# .github/workflows/ci.yml — a complete DevSecOps CI/CD pipeline
name: build-and-scan
on: [push, pull_request]
permissions:
contents: read
security-events: write
id-token: write # OIDC — no long-lived cloud secrets in CI
jobs:
secure-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- name: Secret scanning
uses: gitleaks/gitleaks-action@v2
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- name: Restore (locked)
run: dotnet restore --locked-mode
- name: Vulnerable dependency audit
run: |
dotnet list package --vulnerable --include-transitive \
--format json > audit.json
grep -q '"severity": "\(High\|Critical\)"' audit.json && exit 1 || true
- name: Build with analyzers as errors
run: dotnet build -c Release --no-restore /warnaserror
- name: Test
run: dotnet test -c Release --no-build
- name: Generate SBOM (CycloneDX)
run: |
dotnet tool install --global CycloneDX
dotnet CycloneDX ./src/Api/Api.csproj -o ./sbom
- name: Container scan
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
severity: CRITICAL,HIGH
exit-code: '1'
- uses: actions/upload-artifact@v4
with: { name: sbom, path: ./sbom }
Note id-token: write. Federated OIDC lets your pipeline authenticate to Azure or AWS with a short-lived token instead of a stored secret. Removing static cloud credentials from CI eliminates one of the most valuable targets an attacker can hit — CI runners typically hold production-grade permissions.
Stage 5: Harden the Runtime
Ship containers that run as non-root, with a read-only filesystem and no shell. .NET 8+ chiselled images make this nearly free:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish ./src/Api -c Release -o /app
# Chiselled runtime: no shell, no package manager, ~100MB smaller attack surface
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["./Api"]
Best Practices for Your DevSecOps CI/CD Pipeline
- Fail the build only on High/Critical. Report everything else. Signal-to-noise determines whether your program survives contact with a deadline.
- Baseline existing findings. Legacy code will light up like a Christmas tree. Suppress the existing set, then block all new findings. Ratchet, don't big-bang.
- Commit
packages.lock.json. Reproducible restores are a prerequisite for meaningful scanning. - Use Central Package Management (
Directory.Packages.props) so one version bump fixes every project. - Scan the pipeline itself. Pin GitHub Actions to commit SHAs, not tags — tags are mutable.
- Measure mean time to remediate, not vulnerability count. Count is vanity; MTTR is the number that predicts breach outcomes.
Common Pitfalls
Treating the scanner's output as the definition of "secure." SAST cannot see business logic flaws — the IDOR where user A can read user B's invoice by changing an ID. Threat modelling and code review still carry that load.
Blocking merges on scans that take 25 minutes. Developers will route around it. Run fast checks (secrets, analyzers, unit tests) on every PR; run deep scans (DAST, full SCA) nightly on main.
Ignoring transitive dependencies. Your direct dependency count might be 20. Your transitive count is probably 400. That is where the risk lives — hence --include-transitive.
Security as a separate team's job. If a security engineer is the only person who can read the pipeline output, you have built a bottleneck, not a DevSecOps practice. Findings must land in the developer's PR, in their language, with a suggested fix.
Key Takeaways
A working DevSecOps CI/CD pipeline in 2026 is not an expensive platform purchase — for .NET teams, most of it is already sitting in your SDK waiting to be switched on. Start here:
- Enable
AnalysisMode=All,NuGetAuditMode=allandTreatWarningsAsErrorsinDirectory.Build.propstoday. That is a 30-minute change with outsized return. - Eliminate secrets architecturally with managed identity and
DefaultAzureCredentialrather than detecting them after the fact. - Write the "every endpoint is authorized" test — it prevents the most common serious ASP.NET Core vulnerability class.
- Generate an SBOM on every build so the next zero-day is a grep, not a fire drill.
- Replace static CI credentials with OIDC federation and ship non-root chiselled containers.
- Tune aggressively for signal. A pipeline developers trust beats a comprehensive one they bypass.
Security built in from day one costs a fraction of security bolted on after an incident. Pick one control from this list, ship it this sprint, and let the ratchet do the rest.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment