Added in Unreleased.

ZeeKayDa.Auth ships a Roslyn analyzer package that enforces compile-time log-hygiene requirements for code compiled into one of ZeeKayDa.Auth’s own assemblies. Violations are reported as build errors so that credential-leak paths are caught during development rather than in production.

The analyzer targets code written inside the library itself, not application code that consumes ZeeKayDa.Auth. Consumer-side log hygiene is covered in Configure host-level log hygiene.

ZEEKAYDA0001 and ZEEKAYDA0002 key on the compiled assembly’s name (any assembly named ZeeKayDa.Auth or prefixed ZeeKayDa.Auth., excluding the analyzer package itself), not on the declared C# namespace of the code being analyzed — a ZeeKayDa.Auth extension-method class declared under a Microsoft.* namespace for discoverability is still in scope.

Generated code

ZEEKAYDA0001 and ZEEKAYDA0002 analyze generated code — they do not follow the usual Roslyn convention of skipping files Roslyn classifies as “generated” (by filename, an <auto-generated/> header, a [GeneratedCode] attribute, or generated_code = true in an .editorconfig/.globalconfig). These are security controls enforcing ADR 0007 §7’s log-never list, not a style preference: a log call that leaks client_secret leaks it whether a source generator emitted it or a human typed it. Skipping generated code for these two rules would make marking a file “generated” a suppression vector that names neither rule ID anywhere in the diff.


ZEEKAYDA0001 — Direct ILogger<T> use

Attribute Value
Rule ID ZEEKAYDA0001
Category LogHygiene
Severity Error

What it enforces

ILogger<T> must not be injected directly into a ZeeKayDa.Auth service. All internal services must accept ISanitizingLogger<T> instead. ISanitizingLogger<T> wraps the underlying logger with SecretSanitizingLogger, which redacts known-sensitive OAuth parameters before any log entry reaches the log sink.

Bypassing this wrapper by injecting ILogger<T> directly creates a path through which sensitive values — client_secret, code_verifier, Authorization, and others — can reach the log sink unredacted.

Violation

// ZEEKAYDA0001: ILogger<T> injected directly.
public sealed class TokenEndpointHandler
{
    private readonly ILogger<TokenEndpointHandler> _logger;

    public TokenEndpointHandler(ILogger<TokenEndpointHandler> logger)
    {
        _logger = logger;
    }
}

Compliant alternative

// Correct: ISanitizingLogger<T> used instead.
public sealed class TokenEndpointHandler
{
    private readonly ISanitizingLogger<TokenEndpointHandler> _logger;

    public TokenEndpointHandler(ISanitizingLogger<TokenEndpointHandler> logger)
    {
        _logger = logger;
    }
}

⚠️ Warning: ISanitizingLogger<T> only redacts values passed via named structured-logging placeholders. Log calls that embed sensitive values through string interpolation bypass the redaction layer entirely. See ZEEKAYDA0002 for the rule that catches this.

Suppression

Suppressions require justification and team review. A CI check (.github/scripts/check_log_hygiene.cs) enforces this — not by pattern-matching suppression syntaxes, but by asking MSBuild/Roslyn for the effective severity it would actually resolve for each source file, and asserting it never resolves below Error.

The only sanctioned suppression route is a narrowly-scoped #pragma warning disable (including a bare pragma, since it suppresses this rule too) or [SuppressMessage(...)] attribute naming this rule, carrying a structured // log-hygiene-ok: <reason> (#<issue-or-pr-number>) comment — detected from syntax trivia, so multi-line attributes and const-indirected rule IDs are caught the same as single-line forms.

Project-wide suppression is rejected outright, with no justification-comment escape hatch: a <NoWarn>/<WarningsNotAsErrors> entry (however it is spelled — single-line, multi-line, via Directory.Build.props/.targets, or via MSBuild property indirection), a .editorconfig or .globalconfig severity override (including the bulk dotnet_analyzer_diagnostic.severity and dotnet_analyzer_diagnostic.category-loghygiene.severity keys), <RunAnalyzers>false</RunAnalyzers>, a removed analyzer reference, or a <CodeAnalysisRuleSet> entry downgrading this rule all fail the check — because pass C reads MSBuild’s own evaluation of these properties/items and Roslyn’s own AnalyzerConfigSet resolution of severity, casing and indirection included, it isn’t fooled by any particular spelling of a project-wide downgrade the way a text pattern would be. A DiagnosticSuppressor/SuppressionDescriptor naming this rule is likewise a hard failure — it is a standing, programmatic suppression, not a reviewable single-line comment.

Coverage scope is every project under the /src/ folder of ZeeKayDa.Auth.slnx plus samples/**/*.csproj. tests/ is intentionally exempt — test projects legitimately suppress this rule to assert analyzer behaviour, and test code does not ship.

Residual gaps (each covered by a non-code control, not by this checker):

  • An MSBuild command-line override in CI itself (e.g. dotnet build -p:NoWarn=ZEEKAYDA0001) is outside what evaluating the project file can see — covered by CODEOWNERS review of workflow changes.
  • Deleting the checker or its CI job — no code can defend its own invocation; covered by CODEOWNERS plus required-status-check branch protection on the log-hygiene job.
  • A Roslyn suppression channel this checker doesn’t yet model, including one arriving via a third-party analyzer package — covered by the canary backstop (.github/scripts/canary/ZeeKayDa.Auth.LogHygieneCanary/), a small project containing known-bad code that must always produce both ZEEKAYDA0001 and ZEEKAYDA0002.
  • Weakening the analyzers’ own logic (for example the assembly-name exemption in ZEEKAYDA0001/ZEEKAYDA0002 themselves) is out of scope for this checker — it verifies severity resolution, not analyzer correctness. Covered by tests/ZeeKayDa.Auth.Analyzers.Tests.
  • IL-patched or post-build-rewritten assemblies are outside any source-level check; the runtime SecretSanitizingLogger is the compensating control.
#pragma warning disable ZEEKAYDA0001 // log-hygiene-ok: legacy adapter predates ISanitizingLogger<T>, migration tracked (#123)
private readonly ILogger<TokenEndpointHandler> _logger;
#pragma warning restore ZEEKAYDA0001

⚠️ Warning: Suppressing this rule removes the compile-time safety net for the affected type. Any suppression must be reviewed and justified in a code comment explaining why the ISanitizingLogger<T> wrapper is not applicable. A .editorconfig/.globalconfig severity override is not a valid suppression route for this rule — see the no-hatch rule above.


ZEEKAYDA0002 — Non-constant string in log call

Attribute Value
Rule ID ZEEKAYDA0002
Category LogHygiene
Severity Error

What it enforces

Log*/BeginScope message templates in code compiled into a ZeeKayDa.Auth assembly must be compile-time constant strings. Interpolated strings, variable references, string concatenation with non-literal operands, string.Format, and any other non-constant expression are all flagged regardless of the identifier names involved. This applies to instance calls (logger.LogInformation(...)), conditional-access calls (logger?.LogInformation(...)), and the static-method call form (LoggerExtensions.LogInformation(logger, ...)) alike, including when arguments are passed by name or out of declaration order.

The rule also constrains one specific non-Log* method by symbol rather than by name/receiver heuristic: StartupVerificationContext.AddWarning’s messageTemplate parameter, whether the call is receiver-qualified or (from inside StartupVerificationContext itself) unqualified. AddWarning is how an IStartupVerifier/IStartupVerificationGate implementation reports a warning for the startup-verification runner to log on its behalf (see ADR 0016 §3, §9) — its template flows into the same redaction-sensitive log call the Log* branch above protects, so it needs the same constant-string guarantee even though it isn’t itself a call to ILogger.

The rule also catches a Log*/AddWarning method group captured into a delegate — for example Action<string, object?[]> log = logger.LogInformation; — at the point of assignment. Once the method group has been converted to a delegate, a later call through that delegate variable (log($"leak {secret}", args)) has no ILogger/AddWarning-shaped receiver in its syntax for the rule to recognize, so the conversion itself is flagged instead. This check does not follow the delegate variable any further: reassignment, passing it as a parameter, or storing it in a field across methods are not tracked, and a bypass built that way slips through undetected. It’s also syntactically local to a plain = assignment or initializer: a compound assignment (log += logger.LogInformation;), an explicit delegate construction (new Action<...>(logger.LogInformation)), a conditional expression (flag ? logger.LogInformation : logger.LogWarning), or capturing a fully static method group (e.g. an extension method referenced by its declaring type rather than an ILogger receiver) are not recognized either. All of these require a developer to deliberately reach for an unusual delegate-construction shape rather than ordinary ILogger/AddWarning usage, consistent with this rule’s existing low-severity framing for non-idiomatic bypasses.

Rationale

SecretSanitizingLogger redacts sensitive values by inspecting the structured-logging message template and its named arguments at the point the log entry is written. An interpolated string is fully expanded by the C# compiler before it is passed to the logger — the logger receives a plain string with the sensitive value already embedded. The message template is gone; there are no named placeholders to inspect. The redaction layer therefore has no mechanism to detect or remove the value.

This is a silent credential-leak path: the code compiles and runs without error, log calls appear to work normally, and sensitive values arrive at the log sink in plaintext.

Violation

// ZEEKAYDA0002: interpolated string is not a compile-time constant.
_logger.LogInformation($"Verifying client_secret: {clientSecret}");

// ZEEKAYDA0002: concatenation with a variable is not a compile-time constant.
_logger.LogDebug("Processing request for client: " + clientId);

// ZEEKAYDA0002: local variable (even if it holds a literal) is not a compile-time constant.
var msg = "Starting request";
_logger.LogInformation(msg);

Compliant alternative

// Correct: string literal is a compile-time constant.
_logger.LogInformation("Verifying client_secret: {ClientSecret}", redacted);

// Correct: two string literals concatenated are still a compile-time constant.
_logger.LogInformation("part one {X} " + "part two {Y}", x, y);

// Correct: pass dynamic values as structured-logging arguments, not in the template.
_logger.LogDebug("Processing request for client {ClientId}", clientId);

💡 Tip: If you genuinely need to include a sensitive value for a diagnostic purpose, pass a pre-redacted representation — for example, the first four characters followed by *** — as the structured argument rather than the raw value. Never pass the raw credential.

Suppression

Suppressions require justification and team review, using the same structured // log-hygiene-ok: <reason> (#<issue-or-pr-number>) comment form, the same project-wide no-hatch rule, and the same coverage scope described under ZEEKAYDA0001’s Suppression section. See StartupVerificationHostedService.LogWarning for a real example of the required form.

#pragma warning disable ZEEKAYDA0002 // log-hygiene-ok: diagnostic-only, key material never leaves this dev-only build config (#123)
_logger.LogDebug($"Diagnostic — raw key material: {keyMaterial}");
#pragma warning restore ZEEKAYDA0002

⚠️ Warning: Suppressing this rule disables the compile-time check for the affected call sites. Any suppression must be accompanied by a code comment explaining why the non-constant string is safe at that specific location and confirming that no sensitive value can reach the log sink through the suppressed call. A .editorconfig/.globalconfig severity override is not a valid suppression route for this rule — see the no-hatch rule above.