Certified, Filed, Forgotten: The Compliance Trainwreck
I’ve watched this trainwreck unfold a dozen times: Organization gets certified, consultants cash their checks, comprehensive documentation gets filed somewhere, and then… compliance becomes a Word document ritual. “Just check the Azure portal” before each release. Screenshot some settings. Sign the checklist. Ship it.
Three months later, an audit exposes configuration drift, hardcoded secrets in production, and vulnerable dependencies nobody noticed. The compliance team swears everything was checked. The forensic evidence disagrees.
Here’s what nobody wants to admit: Manual compliance verification is fundamentally broken. Not “could be improved” broken. Broken in ways that would make your auditor weep if they understood software. Screenshots aren’t documentation. Different people finding different issues isn’t “thorough review,” it’s randomness. And “I checked it last Tuesday” isn’t an evidence trail.
The fix isn’t more checklists or stricter sign-off procedures. It’s treating compliance as what it actually is: an engineering problem. One that demands automated tooling in your CI/CD pipeline, not a rotating sacrifice of whoever drew the short straw this sprint.
.NET gives you everything needed: System.CommandLine for CLI interfaces, Roslyn for code inspection, Azure SDK for infrastructure validation. Let me show you what real compliance automation looks like.
The Checklist Ceremony (A Horror Story)
Let’s talk about what I see in organizations that claim to be “compliant.”
The Sacred Document
A Word document titled Security_Compliance_Checklist_v3_FINAL_v2_REALLY_FINAL.docx gets circulated before each release. Someone (usually whoever lost the argument about who has to do it this time) manually verifies items:
☐ Check code for hardcoded secrets (search GitHub for "password", "apikey")
☐ Verify all API endpoints require authentication (manually test 10 endpoints)
☐ Confirm Azure Key Vault configured correctly (log into portal, screenshot settings)
☐ Validate database encryption enabled (run SQL query, save results)
☐ Check dependency versions for known vulnerabilities (visit NuGet.org, manually search)
☐ Confirm HTTPS enforced on all services (curl a few endpoints)
Each check gets marked complete. Someone signs off. The deployment proceeds. Everyone feels very professional.
What Actually Happens (Spoiler: Nothing Good)
Inconsistent execution. Different team members interpret “check for secrets” differently. One searches for “password” in the codebase. Another skips config files because “those don’t count, right?” A third forgets entirely because they’re three days behind on a feature that should have shipped last week.
No evidence trail. The checklist says “completed” but provides zero forensic evidence. An auditor asks, “Can you prove API endpoints required authentication on March 15th?” The answer is a signature and a shrug. Maybe a vague memory of clicking around in the portal.
Configuration drift goes unnoticed. The Azure portal shows correct settings today. Last week when someone disabled encryption for “quick troubleshooting” and forgot to re-enable it? That shipped to production. Nobody knows.
Vulnerable dependencies everywhere. Manually checking NuGet packages takes hours and misses transitive dependencies entirely. By the time someone googles the top-level packages, code with known CVEs is already running in prod.
Environments? What environments? The checklist gets run in production. Maybe. Does staging match? Dev? Nobody’s manually checking every environment every deployment. That would be insane. (It would also be the actual requirement.)
Why Auditors Should Be Furious
The standard requires “regular” compliance reviews. Regular means every deployment, every config change, every commit. Not “quarterly when someone remembers.” Not “weekly if we’re feeling disciplined.”
It also requires documented procedures. Screenshots in Word documents aren’t procedures. They’re artifacts from a specific moment that became lies the instant someone changed something.
Manual compliance theater satisfies auditors who don’t understand software. It doesn’t satisfy the actual requirements. And increasingly, auditors do understand software.
The Fix: Make the Computer Do It
Compliance verification needs to be automated, repeatable, verifiable, fast, and comprehensive. Humans are bad at all of these things. Computers are great at them.
.NET CLI tools deliver exactly this. Here’s how to build them.
Building Your Compliance Scanner
We’re creating a .NET Global Tool that scans code and infrastructure, then fails the pipeline when it finds problems. Simple concept, surprisingly effective.
dotnet new tool -n ComplianceScanner
cd ComplianceScanner
dotnet add package System.CommandLine --version 2.0.2
dotnet add package Microsoft.CodeAnalysis.CSharp --version 5.0.0
dotnet add package Azure.Identity --version 1.17.1
dotnet add package Azure.ResourceManager --version 1.13.2
The core CLI structure is straightforward:
using System.CommandLine;
var rootCommand = new RootCommand("Compliance verification tool");
var scanCommand = new Command("scan", "Scan codebase for violations");
var pathOption = new Option<string>("--path", () => Directory.GetCurrentDirectory());
scanCommand.AddOption(pathOption);
scanCommand.SetHandler(async (path) =>
{
var violations = new List<ComplianceViolation>();
violations.AddRange(await SecretScanner.ScanAsync(path));
violations.AddRange(await AuthScanner.ScanAsync(path));
if (violations.Any())
{
foreach (var v in violations)
Console.WriteLine($"[{v.Severity}] {v.Rule}: {v.Message}");
return 1; // Fails the pipeline
}
return 0;
}, pathOption);
rootCommand.AddCommand(scanCommand);
return await rootCommand.InvokeAsync(args);
The key insight: return non-zero exit codes. That’s what makes pipelines fail. Everything else is details.
Finding Secrets (The Ones Your Team Swears Aren’t There)
Roslyn makes this embarrassingly simple:
public class SecretScanner
{
private static readonly Regex[] Patterns = new[]
{
new Regex(@"(?i)(password|pwd)\s*=\s*[""'][^""']{8,}[""']"),
new Regex(@"(?i)(api[_-]?key)\s*=\s*[""'][^""']{20,}[""']"),
new Regex(@"AKIA[0-9A-Z]{16}"), // AWS keys - always fun to find these
};
public static async Task<List<Violation>> ScanAsync(string path)
{
var violations = new List<Violation>();
foreach (var file in Directory.GetFiles(path, "*.cs", SearchOption.AllDirectories))
{
var tree = CSharpSyntaxTree.ParseText(await File.ReadAllTextAsync(file));
var literals = (await tree.GetRootAsync()).DescendantNodes()
.OfType<LiteralExpressionSyntax>()
.Where(l => l.IsKind(SyntaxKind.StringLiteralExpression));
foreach (var literal in literals)
if (Patterns.Any(p => p.IsMatch(literal.Token.ValueText)))
violations.Add(new("SECRET", "CRITICAL", file, "Hardcoded secret"));
}
return violations;
}
}
You’d be amazed how many “we definitely don’t have hardcoded secrets” codebases light up like Christmas trees when you run this.
Finding Naked Endpoints
Every ASP.NET Core controller endpoint should either have [Authorize] or an explicit [AllowAnonymous] with a documented reason. “We forgot” is not a documented reason.
public class AuthScanner
{
public static async Task<List<Violation>> ScanAsync(string path)
{
var violations = new List<Violation>();
foreach (var file in Directory.GetFiles(path, "*.cs", SearchOption.AllDirectories))
{
var root = await CSharpSyntaxTree.ParseText(
await File.ReadAllTextAsync(file)).GetRootAsync();
foreach (var controller in root.DescendantNodes().OfType<ClassDeclarationSyntax>()
.Where(c => c.Identifier.Text.EndsWith("Controller")))
{
var hasClassAuth = controller.AttributeLists
.SelectMany(a => a.Attributes)
.Any(a => a.Name.ToString().Contains("Authorize"));
foreach (var method in controller.DescendantNodes().OfType<MethodDeclarationSyntax>())
{
var attrs = method.AttributeLists.SelectMany(a => a.Attributes);
var isEndpoint = attrs.Any(a => a.Name.ToString().StartsWith("Http"));
var hasAuth = attrs.Any(a => a.Name.ToString().Contains("Authorize")
|| a.Name.ToString().Contains("AllowAnonymous"));
if (isEndpoint && !hasClassAuth && !hasAuth)
violations.Add(new("AUTH", "HIGH", file,
$"Endpoint '{method.Identifier}' has no auth attribute"));
}
}
}
return violations;
}
}
This catches the “I’ll add authentication later” endpoints that somehow made it to production three years ago.
Infrastructure Reality Checks
Azure SDK lets you verify that what’s actually deployed matches what everyone thinks is deployed. Spoiler: it often doesn’t.
public class InfraValidator
{
private readonly ArmClient _client = new(new DefaultAzureCredential());
public async Task<List<Issue>> ValidateAsync()
{
var issues = new List<Issue>();
await foreach (var sub in _client.GetSubscriptions())
{
// Storage: HTTPS only? Encryption on?
await foreach (var storage in sub.GetStorageAccountsAsync())
{
if (!storage.Data.EnableHttpsTrafficOnly.GetValueOrDefault())
issues.Add(new(storage.Data.Name, "Storage allows HTTP"));
if (storage.Data.Encryption?.Services?.Blob?.Enabled != true)
issues.Add(new(storage.Data.Name, "Blob encryption disabled"));
}
// Key Vault: Soft delete? Purge protection?
await foreach (var vault in sub.GetKeyVaultsAsync())
{
if (!vault.Data.Properties.EnableSoftDelete.GetValueOrDefault())
issues.Add(new(vault.Data.Name, "No soft delete - secrets can vanish"));
}
}
return issues;
}
}
Run this the first time and prepare for uncomfortable conversations about “temporary” configuration changes from 2019.
Dependency Nightmares
Good news: .NET already has this built in. Bad news: nobody’s running it.
public static async Task<List<Violation>> ScanVulnerabilitiesAsync(string path)
{
var process = Process.Start(new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "list package --vulnerable --include-transitive --format json",
WorkingDirectory = path,
RedirectStandardOutput = true,
UseShellExecute = false
});
var output = await process!.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
// Parse JSON, extract vulnerabilities, return violations
// The JSON structure is well-documented and straightforward
}
The --include-transitive flag is crucial. That’s where the real horrors live: three levels deep in dependencies nobody knew existed.
Wiring It Into Your Pipeline
Here’s the GitHub Actions workflow that makes compliance a gate, not a suggestion:
name: Compliance Gate
on: [pull_request, push]
jobs:
compliance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- name: Install Scanner
run: dotnet tool install --global ComplianceScanner
- name: Scan Code
run: compliance-scanner scan --path .
- name: Check Vulnerabilities
run: dotnet list package --vulnerable --include-transitive
- name: Validate Infrastructure
run: compliance-scanner validate-infra
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
Make this a required status check. No exceptions. “But we need to ship!” is exactly when you need it most.
Reports for the Auditors
Generate JSON, HTML, or Markdown reports from your scan results. Auditors love artifacts they can file. More importantly, you love having evidence when someone asks “but did you actually check?”
The report generator is straightforward: serialize your violations to the format of choice, timestamp it, and archive it. The structure matters less than the fact that it exists and is generated automatically.
For the Auditors in the Room
Yes, this satisfies the standard. Specifically:
A.5.36 (Regular compliance review): Running on every PR and deployment is “regular.” Quarterly manual checks are not.
A.8.8 (Vulnerability management): Automated scanning that blocks deployment beats “we’ll check NuGet.org when we remember.”
A.5.37 (Documented procedures): The code is the documentation. It runs the same way every time. Unlike Bob’s interpretation of the checklist.
A.8.24 (Cryptography): Automated validation that encryption is actually enabled, not just assumed to be.
Rolling This Out (Without a Mutiny)
Don’t try to implement everything at once. That’s how you get ignored.
Week 1: Secret scanning. Start with warnings. Let the team see what’s been lurking. Move to failures once the initial panic subsides.
Week 2: Vulnerability checks. Add dotnet list package --vulnerable to the pipeline. Fail on CRITICAL. Watch the dependency update PRs roll in.
Week 3-4: Auth verification. Audit existing endpoints first. You’ll find things. Fix them before enforcing.
Week 5-6: Infrastructure validation. Run manually first. Discover the “temporary” configs. Remediate. Then automate.
Week 7: Reporting. Generate artifacts. Distribute to stakeholders. Watch compliance become boring (which is exactly what you want).
The Bottom Line
Manual compliance is a lie everyone agrees to tell. “We checked” means “someone signed something.” It doesn’t mean the systems are actually secure.
Automated CLI tools change the equation. They run every time. They check the same things. They generate evidence. They fail the build when something’s wrong.
.NET gives you System.CommandLine (finally at GA!), Roslyn, and the Azure SDK. The tooling exists. The patterns are straightforward. The only thing standing between your organization and actual compliance is the decision to stop pretending the Word doc is enough.
Build the scanner. Wire it into the pipeline. Make it a required check.
Or keep signing the checklist. Your call.

Comments