This checklist covers every significant vulnerability class that static application security testing (SAST) should detect in ASP.NET Core and C# codebases — with code examples showing the vulnerable pattern, the secure fix, and the Roslyn rule or SAST finding that catches it.
Use this as a review guide before deploying a new ASP.NET Core application, as a SAST tool evaluation benchmark, or as a structured reference for C# security code reviews.
How to Use This Checklist
Each section maps to a vulnerability class detectable through static code analysis. For each item:
- Roslyn rule — the CA rule that catches it in
dotnet build(single-method patterns only) - SAST required — whether a dedicated SAST tool with interprocedural taint analysis is needed for reliable detection
- Code examples — the vulnerable pattern and the secure replacement
Quick note on Roslyn vs. SAST tools: Roslyn analyzers (built into the .NET SDK) catch obvious single-method patterns and are free. They cannot trace data flows across method calls, class boundaries, or from/to the database. For the majority of injection vulnerabilities in real ASP.NET Core applications — where input travels from a controller through a service layer and into a repository — a dedicated .NET SAST tool with interprocedural taint analysis is required.
1. SQL Injection
1.1 ADO.NET String Concatenation (CWE-89)
Roslyn: CA2100 | SAST: Required for cross-method chains
// ❌ VULNERABLE — user input in SqlCommand text
string username = Request.QueryString["username"];
string sql = "SELECT * FROM Users WHERE Username = '" + username + "'";
var cmd = new SqlCommand(sql, connection);
// ✅ SECURE — parameterized query
var cmd = new SqlCommand("SELECT * FROM Users WHERE Username = @u", connection);
cmd.Parameters.AddWithValue("@u", username);
Checklist:
- No
SqlCommand,OleDbCommand, orOdbcCommandinitialized with string concatenation or interpolation - All
CommandTextvalues are either compile-time constants or parameterized with@paramplaceholders - Stored procedure calls use
CommandType.StoredProcedureand named parameters
1.2 Entity Framework FromSqlRaw Injection (CWE-89)
Roslyn: Not detected | SAST: Required
// ❌ VULNERABLE — string interpolation bypasses EF parameterization
var id = Request.Form["id"];
var user = dbContext.Users
.FromSqlRaw($"SELECT * FROM Users WHERE Id = {id}")
.FirstOrDefault();
// ✅ SECURE — use LINQ (auto-parameterized)
var user = dbContext.Users.FirstOrDefault(u => u.Id == int.Parse(id));
// ✅ ALSO SECURE — FromSqlInterpolated safely parameterizes
var user = dbContext.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Id = {id}")
.FirstOrDefault();
Checklist:
- No
FromSqlRaw()calls with string interpolation ($"...") or concatenation (+) - No
ExecuteSqlRaw()calls with dynamic input -
FromSqlInterpolated()or LINQ queries used for all dynamic data access - Dynamic
ORDER BYcolumns validated against a strict allowlist before embedding in SQL
1.3 Second-Order SQL Injection (CWE-89)
Roslyn: Not detected | SAST: Required (interprocedural + database taint tracking)
// Stage 1: Safe storage (correctly parameterized)
dbContext.Users.Add(new User { Username = username }); // "admin'--" stored safely
// Stage 2: ❌ VULNERABLE — retrieves from DB and uses unsafely
string storedUsername = await GetUsernameFromDb(userId); // Returns "admin'--"
string sql = $"SELECT * FROM Logs WHERE Username = '{storedUsername}'";
// ✅ SECURE — parameterize even database-sourced values
var logs = await dbContext.Logs
.FromSqlInterpolated($"SELECT * FROM Logs WHERE Username = {storedUsername}")
.ToListAsync();
Checklist:
- Database-sourced values treated as untrusted and parameterized in subsequent queries
- No assumption that “it came from our database, so it must be safe”
- SAST tool configured to track taint through database read/write operations
2. Cross-Site Scripting (XSS)
2.1 Razor View XSS via @Html.Raw (CWE-79)
Roslyn: Not detected | SAST: Required
@* ❌ VULNERABLE — bypasses Razor's automatic HTML encoding *@
@Html.Raw(Model.UserBio)
@* ❌ ALSO VULNERABLE — JavaScript context without encoding *@
<script>var userName = '@Model.Username';</script>
@* ✅ SECURE — Razor encodes by default *@
@Model.UserBio
@* ✅ SECURE — JavaScript string encoding for JS context *@
<script>var userName = '@Html.JavaScriptStringEncode(Model.Username)';</script>
Checklist:
- No
@Html.Raw()with user-controlled content - No
@MvcHtmlString.Create()with unencoded input - JavaScript contexts in Razor templates use
@Html.JavaScriptStringEncode() -
Content-Security-Policyheader configured to restrict inline scripts
2.2 HttpResponse.Write XSS (CWE-79)
Roslyn: CA3001 | SAST: Required for multi-method chains
// ❌ VULNERABLE — user input written directly to response
string comment = Request.Form["comment"];
Response.Write("<p>" + comment + "</p>");
// ✅ SECURE — HTML encode before output
Response.Write("<p>" + HttpUtility.HtmlEncode(comment) + "</p>");
Checklist:
- No
Response.Write()orHttpContext.Response.WriteAsync()with unencoded user input - All string interpolation into HTML output uses
HttpUtility.HtmlEncode()or equivalent
3. Command Injection
3.1 Process.Start with User Input (CWE-78)
Roslyn: CA3006 | SAST: Required for cross-method chains
// ❌ VULNERABLE — user input in shell command
string filename = Request.QueryString["file"];
Process.Start("cmd.exe", "/c convert " + filename + " output.pdf");
// ✅ SECURE — validate input + use ArgumentList (no shell parsing)
if (!Regex.IsMatch(filename, @"^[a-zA-Z0-9_\-]+\.pdf$"))
return BadRequest("Invalid filename");
var psi = new ProcessStartInfo
{
FileName = "convert",
UseShellExecute = false
};
psi.ArgumentList.Add(filename);
psi.ArgumentList.Add("output.pdf");
Process.Start(psi);
Checklist:
- No
Process.Start()withArgumentsbuilt by string concatenation or interpolation - When OS commands are required,
ArgumentListused instead of the stringArgumentsproperty -
UseShellExecute = falseto disable shell interpretation of arguments - Input validated against a strict allowlist before use in any OS operation
4. Path Traversal
4.1 File Path with User-Controlled Input (CWE-22)
Roslyn: CA3003 | SAST: Required for cross-method chains
// ❌ VULNERABLE — user controls part of the file path
string filename = Request.QueryString["file"];
string path = Path.Combine("/uploads/", filename);
return File(path, "application/octet-stream");
// ?file=../../etc/passwd → reads system files
// ✅ SECURE — normalize and validate the resolved path
string uploadDir = Path.GetFullPath("/uploads/");
string filePath = Path.GetFullPath(Path.Combine(uploadDir, filename));
if (!filePath.StartsWith(uploadDir, StringComparison.OrdinalIgnoreCase))
return BadRequest("Invalid file path");
return File(filePath, "application/octet-stream");
Checklist:
- All file paths normalized with
Path.GetFullPath()before comparison - Resolved path verified to start with the intended base directory
- No user-supplied filenames used directly in
File(),System.IO.File.ReadAllBytes(), or similar
5. Server-Side Request Forgery (SSRF)
5.1 HttpClient with User-Controlled URL (CWE-918)
Roslyn: Not detected | SAST: Required
// ❌ VULNERABLE — user controls the target URL
string url = Request.Query["url"];
var response = await httpClient.GetAsync(url);
// Attacker: ?url=http://169.254.169.254/latest/meta-data/ → AWS metadata
// ✅ SECURE — validate URL against an allowlist
var uri = new Uri(url);
string[] allowedHosts = { "api.partner.com", "cdn.yoursite.com" };
if (!allowedHosts.Contains(uri.Host, StringComparer.OrdinalIgnoreCase))
return BadRequest("URL not permitted");
var response = await httpClient.GetAsync(uri);
Checklist:
- No
HttpClient.GetAsync(),PostAsync(), or similar with user-controlled URLs - URL allowlist validated against the parsed
Uri.Host(not string matching against the raw URL) - Redirect following disabled or validated (
AllowAutoRedirect = false)
6. Insecure Deserialization
6.1 BinaryFormatter Usage (CWE-502)
Roslyn: CA5360 | SAST: Can detect with type tracking
// ❌ VULNERABLE — arbitrary code execution; deprecated in .NET 5+
BinaryFormatter formatter = new BinaryFormatter();
object obj = formatter.Deserialize(requestStream);
// ✅ SECURE — use System.Text.Json with a specific type
var options = new JsonSerializerOptions { MaxDepth = 32 };
var obj = JsonSerializer.Deserialize<MySpecificType>(json, options);
Checklist:
- No
BinaryFormatter,SoapFormatter, orObjectStateFormatterusage - No
JavaScriptSerializer.Deserialize<object>()or deserialization todynamic/object -
Newtonsoft.JsonTypeNameHandlingnot set toAllorAutowith untrusted input - Deserialization targets a specific, known type — not a base class or
object
7. Weak Cryptography
7.1 Weak Hash Algorithms (CWE-327 / CWE-328)
Roslyn: CA5351 (MD5, DES) | SAST: Required for indirect usage
// ❌ VULNERABLE — MD5 for password hashing
using var md5 = MD5.Create();
byte[] hash = md5.ComputeHash(Encoding.UTF8.GetBytes(password));
// ✅ SECURE — PBKDF2 with SHA-512 (or use ASP.NET Identity's PasswordHasher<T>)
using var kdf = new Rfc2898DeriveBytes(
password,
salt: RandomNumberGenerator.GetBytes(16),
iterations: 600_000,
HashAlgorithmName.SHA512
);
byte[] hash = kdf.GetBytes(32);
7.2 Weak Symmetric Encryption (CWE-327)
// ❌ VULNERABLE — DES has a 56-bit effective key (trivially brute-forced)
using var des = DES.Create();
// ❌ ALSO WEAK — 3DES (112-bit effective key; deprecated by NIST 2023)
using var tdes = TripleDES.Create();
// ✅ SECURE — AES-256-GCM (authenticated encryption)
using var aes = new AesGcm(key, tagSizeInBytes: 16);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
Checklist:
- No
MD5.Create(),SHA1.Create(),DES.Create(),RC2.Create(), orTripleDES.Create()for security-sensitive operations - Password hashing uses
PasswordHasher<T>(ASP.NET Identity),Rfc2898DeriveBytes, or Argon2 - Symmetric encryption uses
AesGcm(authenticated) orAesCbcwith a separate HMAC -
RNGCryptoServiceProviderorRandomNumberGenerator.GetBytes()for all security tokens (notSystem.Random)
8. Hardcoded Secrets
8.1 Connection Strings and API Keys in Source (CWE-798)
Roslyn: Not detected (pattern-only tools catch obvious cases) | SAST: Required
// ❌ VULNERABLE — credentials in source code
private const string ConnectionString =
"Server=prod-db.company.com;Database=App;User=sa;Password=Prod@2024!";
private readonly string _apiKey = "sk-live-abc123def456...";
// ✅ SECURE — credentials from configuration or Key Vault
private readonly string _connectionString;
private readonly string _apiKey;
public MyService(IConfiguration config)
{
_connectionString = config.GetConnectionString("DefaultConnection");
_apiKey = config["ExternalService:ApiKey"];
// Values loaded at runtime from env vars, user secrets, or Azure Key Vault
}
Checklist:
- No connection strings containing
Password=orpwd=in source code - No hardcoded API keys, tokens, or OAuth client secrets as string literals
-
appsettings.jsoncontains no production secrets — only non-sensitive configuration - Git history scanned with
gitleaksortruffleHogfor previously committed secrets - Secrets managed via Azure Key Vault, AWS Secrets Manager, HashiCorp Vault, or equivalent
9. Mass Assignment / Over-Posting
9.1 EF Entity Used Directly as API Model (OWASP A01)
Roslyn: Not detected | SAST: Required (model binding taint tracking)
// ❌ VULNERABLE — binds all entity properties from the request body
[HttpPost]
public async Task<IActionResult> UpdateProfile([FromBody] User user)
{
dbContext.Users.Update(user); // user.IsAdmin, user.Role can be set by attacker
await dbContext.SaveChangesAsync();
return Ok();
}
// ✅ SECURE — DTO with only user-editable fields
public record UpdateProfileDto(string DisplayName, string Email, string? Bio);
[HttpPost]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileDto dto)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var entity = await dbContext.Users.FindAsync(userId);
if (entity is null) return NotFound();
entity.DisplayName = dto.DisplayName;
entity.Email = dto.Email;
entity.Bio = dto.Bio;
// entity.IsAdmin and entity.Role are never touched
await dbContext.SaveChangesAsync();
return Ok();
}
Checklist:
- EF Core entities never used directly as
[FromBody]or[FromQuery]model-bound parameters - DTOs with explicit field lists used for all API input
-
[BindNever]attribute applied to sensitive entity properties where entities must be used
10. CORS Misconfiguration
10.1 Wildcard CORS on Authenticated Endpoints (CWE-942)
Roslyn: Not detected | SAST: Required (CORS middleware configuration analysis)
// ❌ VULNERABLE — wildcard CORS on authenticated API
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin() // * — allows any website to call this API
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials()); // BLOCKED by browsers, but dangerous if reflected
});
// ✅ SECURE — explicit origin allowlist
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
policy.WithOrigins(
"https://app.yourcompany.com",
"https://yourcompany.com")
.AllowAnyMethod()
.WithHeaders("Authorization", "Content-Type")
.AllowCredentials());
});
Checklist:
-
AllowAnyOrigin()not used on endpoints that return user-specific or authenticated data -
AllowAnyOrigin()never combined withAllowCredentials() - CORS origin validation uses exact match or properly anchored suffix checks (no
string.Contains) -
Vary: Originheader added when dynamic origin matching is used
11. Insecure Cookie Configuration
11.1 Missing Cookie Security Flags
Roslyn: Not detected | SAST: Can detect cookie configuration
// ❌ VULNERABLE — session cookie missing security flags
services.AddSession(options =>
{
options.Cookie.Name = "AppSession";
// Missing: HttpOnly, Secure, SameSite
});
// ✅ SECURE — fully hardened cookie configuration
services.AddSession(options =>
{
options.Cookie.Name = "__Session";
options.Cookie.HttpOnly = true; // Prevents JS access
options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // HTTPS only
options.Cookie.SameSite = SameSiteMode.Strict; // Prevents CSRF
options.Cookie.IsEssential = true;
options.IdleTimeout = TimeSpan.FromMinutes(30);
});
Checklist:
- Session cookies set
HttpOnly = true - Session cookies set
SecurePolicy = CookieSecurePolicy.Always - Session cookies set
SameSite = StrictorLaxas appropriate - Authentication cookies use the same security flags
- No sensitive data stored in cookies beyond session identifiers
12. Security Headers
ASP.NET Core does not add security headers by default. Add them via middleware:
// Program.cs — add security headers middleware
app.Use(async (context, next) =>
{
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
context.Response.Headers["X-Frame-Options"] = "DENY";
context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
context.Response.Headers["Permissions-Policy"] =
"camera=(), microphone=(), geolocation=()";
context.Response.Headers["Content-Security-Policy"] =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'";
context.Response.Headers.Remove("Server");
context.Response.Headers.Remove("X-Powered-By");
await next();
});
// HSTS (configure in UseHsts or directly)
app.UseHsts(); // Requires HTTPS; adds Strict-Transport-Security header
Checklist:
-
Content-Security-Policyconfigured and restricts inline scripts -
Strict-Transport-Securityheader present withmax-age≥ 31536000 -
X-Content-Type-Options: nosniffpresent -
X-Frame-Options: DENYorSAMEORIGINpresent -
Referrer-Policyset tostrict-origin-when-cross-originor stricter -
ServerandX-Powered-Byheaders removed - Debug error pages disabled in production (
app.UseExceptionHandler(), notapp.UseDeveloperExceptionPage())
13. Sensitive Data Logging
13.1 EF Core Sensitive Data Logging
Roslyn: Not detected | SAST: Can detect with configuration analysis
// ❌ VULNERABLE — logs SQL parameter values including passwords and PII
services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
options.EnableSensitiveDataLogging(); // ← Never in production
});
// ✅ SECURE — sensitive data logging only in development
services.AddDbContext<AppDbContext>((sp, options) =>
{
options.UseSqlServer(connectionString);
var env = sp.GetRequiredService<IHostEnvironment>();
if (env.IsDevelopment())
{
options.EnableSensitiveDataLogging();
options.LogTo(Console.WriteLine, LogLevel.Information);
}
// In production: parameters logged as @p0, @p1, never their values
});
Checklist:
-
EnableSensitiveDataLogging()not called in production - No logging of raw password strings, API keys, or SSNs
- Log levels appropriate for production (not
LogLevel.Debugglobally) - PII/health data excluded from structured log fields
14. Authentication and Authorization
Checklist:
- All controller actions and Razor Pages have explicit
[Authorize]or[AllowAnonymous]— no reliance on “security by default” -
[Authorize]uses role or policy checks — not just authentication check — where privilege levels matter - Anti-forgery tokens (
[ValidateAntiForgeryToken]) on all state-changing POST endpoints -
[ApiController]attribute used on API controllers (enables automatic model validation) - Password complexity requirements enforced via
PasswordOptionsin Identity configuration - Account lockout enabled via
LockoutOptions(prevent brute force) - MFA available for admin and privileged accounts
- JWT tokens validated for
iss,aud,exp, andalgclaims;alg: nonerejected
SAST Tool Configuration for ASP.NET Core
Roslyn Security Rules to Enable as Build Errors
Add to your .csproj:
<PropertyGroup>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<RunAnalyzersDuringBuild>true</RunAnalyzersDuringBuild>
<WarningsAsErrors>
CA2100; <!-- SQL injection (ADO.NET, single-method) -->
CA3001; <!-- XSS via HttpResponse.Write -->
CA3002; <!-- LDAP injection -->
CA3003; <!-- File path injection -->
CA3006; <!-- OS command injection -->
CA3007; <!-- Open redirect -->
CA5350; <!-- Weak crypto: TripleDES -->
CA5351; <!-- Broken crypto: MD5, DES, RC2 -->
CA5360; <!-- Dangerous BinaryFormatter use -->
CA5369; <!-- Insecure deserialization: XmlSerializer -->
CA5394 <!-- Insecure randomness: System.Random -->
</WarningsAsErrors>
</PropertyGroup>
Roslyn Limitations for ASP.NET Core Security
Roslyn catches obvious single-method patterns. For real enterprise ASP.NET Core applications — where user input flows from a controller action through a service class, through a repository, and into a SqlCommand or FromSqlRaw() call — Roslyn will not fire CA2100 because the dangerous call is not in the same method as the user input.
The table below summarizes what requires a dedicated SAST tool vs. what Roslyn catches:
| Vulnerability | Roslyn | Dedicated SAST |
|---|---|---|
| SQL injection (same method) | ✅ CA2100 | ✅ CA2100 + taint |
| SQL injection (cross-method chain) | ❌ Missed | ✅ Taint analysis |
| Second-order SQL injection | ❌ Missed | ✅ DB taint tracking |
XSS via @Html.Raw() | ❌ Missed | ✅ Razor taint |
| SSRF via HttpClient | ❌ Missed | ✅ Taint analysis |
EF Core FromSqlRaw injection | ❌ Missed | ✅ EF model |
| Hardcoded secrets (non-obvious) | ❌ Missed | ✅ Pattern + taint |
| Mass assignment via model binding | ❌ Missed | ✅ Binding model |
| Weak crypto (indirect call) | ⚠️ Partial | ✅ Type tracking |
For enterprise ASP.NET Core applications, combine Roslyn (fast, in-build feedback) with a dedicated SAST platform (Offensive360) for deep interprocedural taint analysis.
Benchmarking Your SAST Tool Against This Checklist
To evaluate whether your .NET SAST tool reliably detects the vulnerability classes above:
- Create test cases for each item — write a minimal ASP.NET Core controller that exhibits the vulnerable pattern across multiple class boundaries
- Run your SAST tool and check which findings it reports
- Specifically test cross-method injection — if SQL injection that spans a controller → service → repository chain is not found, the tool is using pattern matching, not taint analysis
- Test EF Core patterns —
FromSqlRaw()with string interpolation is the most common EF injection finding; if the tool misses it, .NET EF Core coverage is inadequate - Test second-order SQL injection — stage a write operation followed by a read-then-query sequence; only tools with database taint tracking will find this
If you want to see how Offensive360 SAST performs against your specific ASP.NET Core codebase, a one-time scan for $500 provides a full report within 48 hours — no subscription required.
Frequently Asked Questions
Is Roslyn analysis enough to secure an ASP.NET Core application?
Roslyn analyzers are a valuable first layer — free, integrated into the build, and zero additional configuration for obvious patterns. However, they cannot detect cross-method injection chains, second-order SQL injection, XSS in Razor views via @Html.Raw(), or SSRF. For applications with real security requirements, Roslyn + a dedicated SAST tool is the correct approach.
How do I run this checklist in CI/CD?
Add Roslyn security rules as build errors in your .csproj (see the SAST tool configuration section above). Add a dedicated SAST scan to your GitHub Actions or Azure DevOps pipeline for interprocedural findings. Block PR merges on any Critical findings. See the full CI/CD setup guide for .NET SAST for pipeline examples.
What SAST rules find Entity Framework security issues?
EF Core-specific rules that a capable .NET SAST tool should detect include: FromSqlRaw() called with a string interpolation or concatenation argument (SQL injection), ExecuteSqlRaw() with dynamic input (SQL injection), and entities used directly as model-bound API parameters without DTO filtering (mass assignment). See the EF Core security guide for full details.
Does this checklist apply to .NET Framework (4.x) as well?
Most items apply to both .NET Framework and modern .NET. Differences: BinaryFormatter is deprecated in .NET 5+ but still present in .NET Framework; AesGcm is not available in .NET Framework (use AesCryptoServiceProvider with CipherMode.CBC and a separate HMAC); HSTS middleware differs. A capable .NET SAST tool should support both — verify Framework 4.x support before purchasing.
Offensive360 performs deep interprocedural taint analysis on ASP.NET Core and .NET Framework codebases, detecting all vulnerability classes in this checklist including cross-method injection chains, second-order SQL injection, and EF Core misuse. Run a one-time C# SAST scan for $500 or book a demo to see the full platform.