ConfigContraband 0.3.1

dotnet add package ConfigContraband --version 0.3.1
                    
NuGet\Install-Package ConfigContraband -Version 0.3.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ConfigContraband" Version="0.3.1">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ConfigContraband" Version="0.3.1" />
                    
Directory.Packages.props
<PackageReference Include="ConfigContraband">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ConfigContraband --version 0.3.1
                    
#r "nuget: ConfigContraband, 0.3.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ConfigContraband@0.3.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ConfigContraband&version=0.3.1
                    
Install as a Cake Addin
#tool nuget:?package=ConfigContraband&version=0.3.1
                    
Install as a Cake Tool

<p align="center"> <img src="assets/configcontraband-icon.png" width="96" height="96" alt="ConfigContraband icon"> </p>

ConfigContraband

CI CodeQL codecov

Stop smuggling broken appsettings into production.

ConfigContraband is a high-signal Roslyn analyzer for .NET configuration, ASP.NET Core Options, appsettings.json, ValidateOnStart(), and ValidateDataAnnotations(). It catches the configuration mistakes that compile cleanly, pass code review, and then fail at startup or, worse, on first use.

It focuses on the boring production failures:

  • a section name typo in BindConfiguration(...)
  • a required configuration key missing from all visible appsettings*.json files
  • validation that exists but does not run on startup
  • [Required] properties that are never wired into Options validation
  • nested options that look validated but are silently skipped
  • misspelled JSON keys hiding under a bound section
  • strict binding that will throw because an unknown key is present

Use it when your app relies on strongly typed options and you want configuration validation feedback in the editor, in pull requests, and in CI before a bad setting reaches production.

Feature Snapshot

Area What ConfigContraband does
Section binding Checks supported options bindings against visible appsettings.json and appsettings.*.json files.
Required keys Warns when a DataAnnotations-required key is missing from all visible configuration files.
Startup validation Flags options validation that is registered but not forced to run at startup.
DataAnnotations Finds [Required], [Range], and inherited validation attributes without ValidateDataAnnotations().
Nested validation Detects nested options objects and collections that need recursive validation attributes.
JSON key drift Reports likely misspelled keys under bound sections while staying conservative for flexible binding shapes.
Strict binding Warns when ErrorOnUnknownConfiguration makes an unknown key a binding failure.

Install

<PackageReference Include="ConfigContraband" Version="0.3.1" PrivateAssets="all" />

The package includes buildTransitive props that pass visible appsettings.json and appsettings.*.json files to the analyzer automatically. Add the package, build, and let your editor or CI tell you when your options contract and configuration drift apart.

No runtime dependency is added to your app. ConfigContraband runs as an analyzer during build and in supported IDEs.

What It Looks At

ConfigContraband analyzes options registrations shaped like this:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe");

Named options use the same supported OptionsBuilder<T> shape:

services.AddOptions<StripeOptions>("tenant")
    .BindConfiguration("Stripe")
    .ValidateDataAnnotations()
    .ValidateOnStart();

It also recognizes the common explicit-section style:

services.AddOptions<StripeOptions>()
    .Bind(configuration.GetSection("Stripe"));

services.Configure<StripeOptions>(
    configuration.GetSection("Stripe"));

The section name must be a compile-time string literal. The analyzer follows normal fluent chains:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe")
    .ValidateDataAnnotations()
    .ValidateOnStart();

It also follows immediate same-block local OptionsBuilder<T> chains:

var optionsBuilder = services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe");

optionsBuilder.ValidateDataAnnotations();
optionsBuilder.ValidateOnStart();

The same tracking works when binding happens after the builder is declared:

var optionsBuilder = services.AddOptions<StripeOptions>();
optionsBuilder.BindConfiguration("Stripe");
optionsBuilder.ValidateDataAnnotations();
optionsBuilder.ValidateOnStart();

Adjacent validation calls on that same local builder may appear in the builder initializer, before the bind statement, or after it. The scan stops at unrelated statements instead of guessing across wider control flow.

When the analyzer cannot prove a configuration shape statically, it stays quiet. The goal is high-signal feedback, not noisy guesses.

Rules

ID Rule Default Catches
CFG001 Bound configuration section does not exist Warning BindConfiguration("Strpie") when only Stripe exists.
CFG002 Required configuration key is missing Warning [Required] reference, string, or nullable value property missing from all visible appsettings*.json sections when DataAnnotations validation is enabled.
CFG003 Options validation does not run on startup Warning Validation is registered but ValidateOnStart() is missing.
CFG004 DataAnnotations are not enabled for options validation Warning [Required], [Range], inherited annotations, or IValidatableObject without ValidateDataAnnotations().
CFG005 Nested options validation is not recursive Warning Nested objects or item types with annotations or IValidatableObject, but no recursive validation attribute.
CFG006 Unknown configuration key under bound section Info JSON keys that do not match bindable options properties or aliases.
CFG007 Unknown configuration key will throw during binding Warning JSON keys that do not match bindable options properties while ErrorOnUnknownConfiguration is enabled.

Fast Feedback Loop

The repository includes a showcase project with one intentional example for each rule:

dotnet build samples/ConfigContraband.Showcase/ConfigContraband.Showcase.csproj --configuration Release --no-incremental

The sample stays out of the main solution so normal development builds remain clean.

Rule Details

CFG001: The Section Must Exist

If your code binds "Stripe", a visible appsettings.json or appsettings.*.json file should contain a matching Stripe section.

Before:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Strpie")
    .ValidateDataAnnotations()
    .ValidateOnStart();
{
  "Stripe": {
    "ApiKey": "secret"
  }
}

After:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe")
    .ValidateDataAnnotations()
    .ValidateOnStart();

When ConfigContraband sees a likely typo, it can offer a code fix. The fix keeps regular, verbatim, and raw string literal style when replacing the section name, falling back to an escaped string literal if a raw replacement would need line breaks. Nested section paths use the same colon-separated shape as .NET configuration:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Features:Stripe")
    .ValidateDataAnnotations()
    .ValidateOnStart();
{
  "Features": {
    "Stripe": {
      "ApiKey": "secret"
    }
  }
}

For nested typos, the fix keeps the parent path and replaces only the bad leaf section. If the code says Features:Strpie and the file contains Features:Stripe, the fix changes it to Features:Stripe.

The analyzer checks every visible appsettings.json and appsettings.*.json additional file for section existence, including commented files, JSON string escapes, colon-delimited keys such as "Features:Stripe", and duplicate JSON section members when resolving nested section paths. Lookalike files such as appsettingsBackup.json are ignored. It stays quiet when no appsettings files are available because it cannot prove what configuration exists at runtime.

CFG002: Required Configuration Keys Must Be Present

CFG002 runs when a supported binding has a visible DataAnnotations validation path. That includes OptionsBuilder<TOptions> chains with ValidateDataAnnotations() and direct Configure<TOptions>(GetSection(...)) calls when the same top-level block also registers matching AddOptions<TOptions>().ValidateDataAnnotations(). It reports [Required] reference, string, or nullable value properties that are missing from every visible appsettings.json and appsettings.*.json section for that binding.

Before:

public sealed class StripeOptions
{
    [Required]
    public string ApiKey { get; set; } = "";
}

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe")
    .ValidateDataAnnotations()
    .ValidateOnStart();
{
  "Stripe": {
  }
}

After:

{
  "Stripe": {
    "ApiKey": "secret"
  }
}

The rule follows the same runtime validation boundaries as Options validation. C# required members are compile-time object-initializer checks, not DataAnnotations validation, so they are not reported. [Required] on non-nullable value types is also ignored because the default value is not null. Nested object and collection items are checked only when recursive validation attributes make Options validation walk those values; dictionary value objects stay quiet.

CFG003: Validation Should Run When The App Starts

Options validation often runs later, when options are first used. ValidateOnStart() moves that failure to startup, where it belongs.

Before:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe")
    .ValidateDataAnnotations();

After:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe")
    .ValidateDataAnnotations()
    .ValidateOnStart();

The analyzer tracks validation calls on the same fluent chain whether they appear before or after the binding call. The code fix appends ValidateOnStart() in the same style as the existing registration chain, including multiline chains and immediate same-block local OptionsBuilder<T> chains where binding happens in the initializer or a later local statement. For later local bind statements, adjacent validation calls on the same local are recognized from the builder initializer, before the bind, and after the bind. Registrations that start with AddOptionsWithValidateOnStart<TOptions>() already run validation at startup, so CFG003 stays quiet for that shape.

CFG003 only treats the framework OptionsBuilder<TOptions>.Validate(...), ValidateDataAnnotations(), and ValidateOnStart() APIs as validation signals. Custom extension methods with the same names are ignored unless they call the framework APIs in a shape the analyzer can see.

CFG004: DataAnnotations Must Be Switched On

Attributes such as [Required] do nothing for Options validation unless ValidateDataAnnotations() is registered. Inherited bindable properties count too, including inherited get-only properties populated through a derived constructor, so a base options class with DataAnnotations still needs validation enabled on the derived options registration. Type-level validation attributes are included as well, because DataAnnotations evaluates ValidationAttributes on the options object itself. Nested options graphs count too: if a nested object or list-style collection item has DataAnnotations and is part of the bindable options graph, including constructor-bound records/classes or initialized get-only object or collection properties, the root registration still needs ValidateDataAnnotations(). Constructor-bound properties are included only for the single-public-parameterized-constructor shape the runtime binder supports. If a binding call explicitly sets BindNonPublicProperties = true on the actual binder-options lambda parameter, public properties with private setters are counted too. IValidatableObject is also part of DataAnnotations validation, so options types that implement it need the same registration.

Before:

public class BillingOptions
{
    [Required]
    public string ApiKey { get; set; } = "";
}

public sealed class StripeOptions : BillingOptions
{
    public string WebhookSecret { get; set; } = "";
}

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe")
    .ValidateOnStart();

After:

services.AddOptions<StripeOptions>()
    .BindConfiguration("Stripe")
    .ValidateDataAnnotations()
    .ValidateOnStart();

Validate(...) counts as validation for CFG003, but it does not satisfy CFG004 when DataAnnotations attributes are present.

The analyzer recognizes ValidateDataAnnotations() on the same fluent chain before or after the binding call. The code fix preserves existing fluent-chain formatting, adds ValidateDataAnnotations(), and only adds ValidateOnStart() when startup validation is not already present, including registrations started with AddOptionsWithValidateOnStart<TOptions>().

Like CFG003, CFG004 symbol-checks the framework validation extension methods. A project-local helper named ValidateDataAnnotations(...) does not satisfy the rule by name alone.

CFG005: Nested Options Need Recursive Validation

DataAnnotations do not automatically walk into child objects or collection items. If a nested class or list-style collection item has property-level or type-level validation attributes, or implements IValidatableObject anywhere in its bindable object graph, mark each parent property that should be checked recursively. Initialized get-only object and mutable collection properties count because the configuration binder can populate their existing instances. Public private-set nested properties also count when the binding call opts into BindNonPublicProperties on the actual binder-options lambda parameter.

Before:

public sealed class AppOptions
{
    public DatabaseOptions Database { get; set; } = new();
}

public sealed class DatabaseOptions
{
    [Required]
    public string ConnectionString { get; set; } = "";
}

After:

using Microsoft.Extensions.Options;

public sealed class AppOptions
{
    [ValidateObjectMembers]
    public DatabaseOptions Database { get; set; } = new();
}

public sealed class DatabaseOptions
{
    [Required]
    public string ConnectionString { get; set; } = "";
}

For arrays and other IEnumerable<T> option collections, use [ValidateEnumeratedItems]. Constructor-bound nested records/classes are included when there is exactly one public parameterized constructor and its parameters map to public properties, including inherited public properties. The code fix updates the file that owns the options property, uses a property: attribute target for record constructor parameters, including [property: ValidateEnumeratedItems] on constructor-bound collection parameters, adds using Microsoft.Extensions.Options; when needed, respects namespace-local using blocks, avoids project-local attribute name conflicts, and keeps existing property comments in place. CFG005 does not report interface-typed nested properties, dictionary value objects, or system scalar types because the Options validator cannot safely infer a concrete object graph for those shapes.

CFG006: Config Keys Should Match Options Properties

Keys under a bound section should match public bindable properties. Public settable properties are bindable, constructor-bound records/classes are bindable when there is exactly one public parameterized constructor and its parameters map to public properties, including inherited public properties, and initialized get-only object or mutable collection properties are treated as bindable because the runtime binder can populate them. Public private-set properties are treated as bindable only when the registration explicitly sets BindNonPublicProperties = true on the actual binder-options lambda parameter. If a property-bound option uses [ConfigurationKeyName], that configured name replaces the CLR property name for matching. Constructor-bound properties use constructor parameter keys, matching the runtime binder; if the property is also settable after construction, including a private setter enabled by BindNonPublicProperties, a [ConfigurationKeyName] alias is accepted when the constructor key is present or the constructor parameter has a default value. JSON string escapes are decoded before matching, so escaped property names are treated the same as their runtime configuration keys.

Before:

{
  "Stripe": {
    "ApiKey": "secret",
    "WebookSecret": "typo"
  }
}
public sealed class StripeOptions
{
    public string ApiKey { get; set; } = "";
    public string WebhookSecret { get; set; } = "";
}

After:

{
  "Stripe": {
    "ApiKey": "secret",
    "WebhookSecret": "secret"
  }
}

CFG006 is informational because .NET configuration binding allows flexible shapes. It is still useful for catching the typos that hide in environment-specific settings.

Visible appsettings.json and appsettings.*.json files are treated as a merged configuration view for unknown-key checks, including files with // or /* ... */ comments and files that use colon-delimited keys such as "Features:Stripe:WebhookSecret". Sibling flattened keys under the same nested object are projected into one logical configuration node before analysis. If a bound section appears in appsettings.json and appsettings.Production.json, keys from both files are checked. Nested options objects, arrays or lists of nested options objects, strongly typed dictionary values, and dictionary values that bind to collections of nested options objects are checked recursively, so typos under Servers:0:Port, Servers:primary:Port, or ServersByRegion:eu:0:Port-style data can still be found. Private-set properties are included for registrations that opt into BindNonPublicProperties.

Dictionary entry names and scalar array items are treated as values rather than property names. Arbitrary keys under Dictionary<string, string> and values inside string[] are not reported as unknown options properties.

CFG007: Strict Binding Turns Unknown Keys Into Failures

CFG006 is informational by default because .NET configuration binding is flexible. When a binding call explicitly enables BinderOptions.ErrorOnUnknownConfiguration, the same unknown-key shape becomes a binding exception instead of harmless drift.

Before:

services.AddOptions<StripeOptions>()
    .BindConfiguration(
        "Stripe",
        options => options.ErrorOnUnknownConfiguration = true)
    .ValidateDataAnnotations()
    .ValidateOnStart();
{
  "Stripe": {
    "ApiKey": "secret",
    "WebookSecret": "typo"
  }
}

After:

{
  "Stripe": {
    "ApiKey": "secret",
    "WebhookSecret": "secret"
  }
}

CFG007 mostly follows the same property graph as CFG006, but only reports when the final value of ErrorOnUnknownConfiguration is provably constant true on the actual binder-options lambda parameter. It also catches strict-mode failures that loose binding allows, including [ConfigurationKeyName] alias keys rejected by the current strict binder, object-shaped data under scalar properties such as "ApiKey": { "Foo": "x" }, null/default-initialized settable nested objects, constructor-initialized get-only object values, rejected object-shaped entries inside scalar collections or dictionaries, and unknown object keys behind nested dictionaries, including object collections. CLR property names on scalar objects, null CLR-only nullable values, open interface/object declared or value shapes, property- or constructor-initialized polymorphic reference shapes, matching initializer- or constructor-prepopulated polymorphic dictionary entries including ignore-case dictionary comparers, and nested dictionary entries that the strict binder accepts, unrelated BinderOptions instances, escaped binder-options helper calls, non-constant assignments, compound writes, assignments reset to false, early-return/control-flow cases, and default binding behaviour stay quiet or on the existing CFG006 informational path.

Design Principles

  • Prefer warnings for configuration failures that are likely to break production.
  • Keep flexible binding shapes quiet when static proof is weak.
  • Offer fixes only when the rewrite is narrow and deterministic.
  • Treat appsettings.json and appsettings.*.json as the contract your options classes are supposed to honor.

Current Scope

ConfigContraband currently focuses on:

  • appsettings.json and appsettings.*.json files.
  • AddOptions<T>().BindConfiguration("Section") registrations.
  • AddOptions<T>().Bind(configuration.GetSection("Section")) and GetRequiredSection(...) registrations.
  • Direct Configure<T>(configuration.GetSection("Section")) registrations for section and JSON-key drift.
  • Strict ErrorOnUnknownConfiguration binder options for unknown-key failures.
  • String-literal section names.
  • Public bindable properties on options types, including inherited and constructor-bound bindable properties.
  • [ConfigurationKeyName] key-name overrides.
  • Normal fluent chains and immediate same-block local OptionsBuilder<T> chains.

It does not try to prove every possible dynamic configuration shape. When the analyzer cannot see enough static information, it stays quiet.

There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.1 91 5/20/2026
0.2.0 97 5/19/2026
0.1.11 95 5/5/2026
0.1.10 90 5/3/2026
0.1.9 89 5/3/2026
0.1.8 95 4/29/2026
0.1.7 97 4/29/2026
0.1.6 99 4/29/2026
0.1.5 94 4/29/2026
0.1.4 95 4/29/2026
0.1.3 100 4/29/2026
0.1.2 93 4/29/2026
0.1.1 106 4/28/2026
0.1.0 95 4/28/2026

Harden CFG002 to match runtime DataAnnotations behaviour: required-key diagnostics now stay quiet for C# required members, non-nullable value types, direct Configure<T>(GetSection(...)) registrations without validation, and dictionary value object graphs that Options validation does not recursively validate, while separately validated direct bindings still report missing required keys.