diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..99c62c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build-test-pack: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + - run: dotnet restore WhyConfig.slnx + - run: dotnet test WhyConfig.slnx -c Release --no-restore + - run: dotnet pack src/WhyConfig.Cli/WhyConfig.Cli.csproj -c Release --no-restore diff --git a/.gitignore b/.gitignore index 0ec7954..a6ec662 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ obj/ .vs/ TestResults/ *.user +artifacts/ +.tools/ diff --git a/README.md b/README.md index fca2184..f3480d0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,99 @@ # WhyConfig.NET -Explain which .NET configuration provider supplied a key and which earlier values it replaced. +**Why did my .NET app get this configuration value?** WhyConfig.NET shows every provider that supplied a key, in precedence order, and marks the provider that won. -Development is organized into small pull requests. See the pull request stack for the library, CLI, and documentation. +```text +Database:Host + +appsettings.json + db.production.com + overridden + +appsettings.Development.json + localhost + overridden + +Environment Variables + db.docker + WINNER + +Effective value: db.docker +``` + +The project has two entry points: + +- **WhyConfig.Core** inspects an application's actual `IConfigurationRoot`. Use it when you need the exact provider chain, including custom providers. +- **whyconfig** is a CLI for investigating a project outside the running app. It rebuilds the common .NET application configuration layers from the files and environment available to the CLI. + +## Quick start + +Requires the .NET 10 SDK. From this repository: + +```bash +dotnet run --project src/WhyConfig.Cli -- explain Database:Host --project path/to/YourApp --environment Development +``` + +The project path may be a directory or a `.csproj` file. If omitted, it defaults to the current directory. For a shell command named `whyconfig`, build and install the local tool package: + +```bash +dotnet pack src/WhyConfig.Cli -c Release -o artifacts +dotnet tool install WhyConfig.NET --tool-path .tools --add-source artifacts +``` + +Then run `.tools/whyconfig explain Database:Host --project path/to/YourApp` on macOS/Linux, or `.tools\whyconfig.exe explain Database:Host --project path\to\YourApp` on Windows. + +## CLI options + +```text +whyconfig explain [options] + +--project Project to inspect +--environment Environment to use +--secrets-id Explicit User Secrets ID +--app-arg Application argument; repeatable +--show-secrets Show values for keys that look sensitive +--json Emit machine-readable JSON +``` + +For example, if the application was launched with an argument that overrides `Api:Url`: + +```bash +whyconfig explain Api:Url --project ./MyApp --environment Development --app-arg Api:Url=http://localhost:5000 +``` + +The CLI reads `appsettings.json`, then `appsettings.{Environment}.json`, then User Secrets in `Development` when it can find a `UserSecretsId`, then its own process environment variables, then any `--app-arg` values. `--secrets-id` supplies the ID directly if it is not declared in the selected `.csproj`. If no environment is supplied, the CLI uses `DOTNET_ENVIRONMENT`, then `ASPNETCORE_ENVIRONMENT`, then `Production`. Supply `--environment` when the application's environment is different or uncertain. + +Exit code `0` means the key was found, `1` means no provider supplied it, and `2` means the command or input was invalid. + +## Inspect the running application's configuration + +Reference `src/WhyConfig.Core/WhyConfig.Core.csproj` from your application, then call the library with the actual root: + +```csharp +using WhyConfig.Core; + +var builder = WebApplication.CreateBuilder(args); +var explanation = ConfigExplainer.Explain(builder.Configuration, "Database:Host"); + +Console.WriteLine($"Winner: {explanation.Winner?.Provider}"); +Console.WriteLine($"Effective value: {explanation.EffectiveValue}"); +foreach (var source in explanation.Sources) +{ + Console.WriteLine($"{source.Provider}: {source.Value} ({(source.IsWinner ? "winner" : "overridden")})"); +} +``` + +`IConfigurationRoot.Providers` contains the registered providers in order; later providers take precedence when they contain the same key. The library calls each provider's `TryGet` and reads the effective value from the root. This follows the [Microsoft configuration documentation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-10.0). + +## Accuracy and sensitive values + +The CLI reconstructs a **common default** configuration setup. It cannot discover custom providers, application-specific registration order, launch arguments that were not supplied through `--app-arg`, or environment variables inside a different process or container. For an exact answer about a running app, call `WhyConfig.Core` with that app's `IConfigurationRoot`. + +The CLI masks values for keys with names such as `Password`, `Token`, `Secret`, `ApiKey`, and `ConnectionString` unless `--show-secrets` is set. This is a naming heuristic; other keys may still contain sensitive data. The library returns raw values to its caller. + +## Development + +```bash +dotnet test WhyConfig.slnx -c Release +dotnet pack src/WhyConfig.Cli -c Release -o artifacts +``` diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/WhyConfig.Cli/CliApplication.cs b/src/WhyConfig.Cli/CliApplication.cs index 2729549..48a91ac 100644 --- a/src/WhyConfig.Cli/CliApplication.cs +++ b/src/WhyConfig.Cli/CliApplication.cs @@ -92,7 +92,15 @@ private static void WriteExplanation(ConfigurationExplanation explanation, CliOp private static bool IsSensitiveKey(string key) { - var leaf = key.Split(':').Last().Replace("_", "", StringComparison.Ordinal).ToLowerInvariant(); + var segments = key.Split(':') + .Select(segment => segment.Replace("_", "", StringComparison.Ordinal).ToLowerInvariant()) + .ToArray(); + if (segments.Any(segment => segment is "connectionstrings" or "credentials" or "secrets")) + { + return true; + } + + var leaf = segments[^1]; return leaf is "key" or "credential" or "connectionstring" || leaf.Contains("password", StringComparison.Ordinal) || leaf.Contains("secret", StringComparison.Ordinal) diff --git a/src/WhyConfig.Cli/WhyConfig.Cli.csproj b/src/WhyConfig.Cli/WhyConfig.Cli.csproj index 10d130d..9ecfd03 100644 --- a/src/WhyConfig.Cli/WhyConfig.Cli.csproj +++ b/src/WhyConfig.Cli/WhyConfig.Cli.csproj @@ -20,6 +20,11 @@ whyconfig WhyConfig.NET Explain .NET configuration provider precedence for a key. + README.md + + + + diff --git a/tests/WhyConfig.Cli.Tests/CliApplicationTests.cs b/tests/WhyConfig.Cli.Tests/CliApplicationTests.cs index 0916169..e2d5565 100644 --- a/tests/WhyConfig.Cli.Tests/CliApplicationTests.cs +++ b/tests/WhyConfig.Cli.Tests/CliApplicationTests.cs @@ -52,6 +52,20 @@ public void RedactsSensitiveValuesInJsonUnlessRequested() Assert.Contains("super-secret-value", revealed); } + [Fact] + public void RedactsConnectionStringsSection() + { + using var project = new TempProject(); + project.Write("appsettings.json", """{"ConnectionStrings":{"Default":"Server=db;Password=sensitive"}}"""); + + var (code, output, _) = Run("explain", "ConnectionStrings:Default", "--project", project.Path, + "--environment", "Production"); + + Assert.Equal(0, code); + Assert.Contains("", output); + Assert.DoesNotContain("sensitive", output); + } + [Fact] public void ReportsMissingKeyWithExitCodeOne() {