Skip to content

Commit 7c7fb2d

Browse files
committed
Stop embedding secret-shaped literals for the production-defaults guard
SonarCloud's hard-coded-secret rule flags any string that structurally resembles a key/password next to a suspiciously-named field, regardless of whether it's a real secret — it caught both the plaintext defaults and their SHA-256 hashes in the previous two attempts. ProductionSecretsGuard now takes two ProductionSecretsSnapshot values (the bound Production configuration, and a fresh runtime read of the base appsettings.json) and compares them structurally, so the 'dev default' is never written down as a literal in source — Program.cs re-reads appsettings.json itself to build the baseline. Tests use synthetic placeholder strings instead of the real dev-default values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoQrUqEN5AoByRCVYD6Qrt
1 parent 515da61 commit 7c7fb2d

4 files changed

Lines changed: 111 additions & 88 deletions

File tree

src/Anything.API/Program.cs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
using Microsoft.AspNetCore.HttpOverrides;
1515
using Microsoft.AspNetCore.RateLimiting;
1616
using Microsoft.EntityFrameworkCore;
17+
using Microsoft.Extensions.Configuration;
1718
using Microsoft.Extensions.Options;
1819
using Microsoft.IdentityModel.Tokens;
1920

@@ -196,16 +197,35 @@
196197

197198
// Fails startup (crash loop, caught by the deploy's /health verification) rather
198199
// than serving Production traffic with the dev secrets from appsettings.json.
199-
// The checks themselves live in ProductionSecretsGuard (unit-tested).
200+
// The comparison itself lives in ProductionSecretsGuard (unit-tested); the
201+
// "dev default" it compares against is read from the base appsettings.json at
202+
// runtime rather than hard-coded here, so this file never embeds a
203+
// secret-shaped literal for static analysis to flag.
200204
static void ValidateProductionSecrets(WebApplication app)
201205
{
202206
if (!app.Environment.IsProduction())
203207
return;
204208

205-
var errors = ProductionSecretsGuard.FindDevDefaults(
206-
app.Services.GetRequiredService<IOptions<JwtSettings>>().Value,
207-
app.Services.GetRequiredService<IOptions<AdminSettings>>().Value,
208-
app.Services.GetRequiredService<IOptions<ImageSettings>>().Value);
209+
var configured = new ProductionSecretsSnapshot(
210+
app.Services.GetRequiredService<IOptions<JwtSettings>>().Value.SecretKey,
211+
app.Services.GetRequiredService<IOptions<AdminSettings>>().Value.Password,
212+
app.Services.GetRequiredService<IOptions<ImageSettings>>().Value.SecretKey,
213+
app.Services.GetRequiredService<IOptions<ImageSettings>>().Value.ImageProxyKey,
214+
app.Services.GetRequiredService<IOptions<ImageSettings>>().Value.ImageProxySalt);
215+
216+
var baseFile = new ConfigurationBuilder()
217+
.SetBasePath(app.Environment.ContentRootPath)
218+
.AddJsonFile("appsettings.json", optional: true)
219+
.Build();
220+
221+
var baseline = new ProductionSecretsSnapshot(
222+
baseFile[$"{JwtSettings.SectionName}:SecretKey"],
223+
baseFile[$"{AdminSettings.SectionName}:Password"],
224+
baseFile[$"{ImageSettings.SectionName}:SecretKey"],
225+
null,
226+
null);
227+
228+
var errors = ProductionSecretsGuard.FindDevDefaults(configured, baseline);
209229

210230
if (errors.Count > 0)
211231
throw new InvalidOperationException(

src/Anything.API/agent.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ Thin HTTP layer. Endpoints extract request data and dispatch to `IMediator.Send(
2525
- **Auth on the endpoint is not scoping.** `.RequireAuthorization()` + `HouseholdMiddleware` only prove the caller is *a member* of the header's household — the handler's own query must still filter by `IHouseholdContext.HouseholdId`, or every member of any household can read/write the row. Both halves are required for every new household-scoped endpoint.
2626
- **`HouseholdMiddleware`'s exempt prefixes** (`/api/auth`, `/api/households`, `/api/events`, `/api/shared`, `/swagger`) skip the membership check entirely, so endpoints under them enforce their own access rules in the handler. Adding a prefix to that list needs the same justification. The middleware also costs one DB query per request — don't add further per-request middleware queries.
2727
- **SSE (`/api/events`) broadcasts every event to every connected client, across households.** `SyncEvent` must stay type + optional id — never put entity data (names, amounts) in it. Its auth token rides the query string (EventSource can't set headers): don't log query strings anywhere, and don't reuse the query-token pattern on other endpoints.
28-
- **The checked-in `appsettings.json` secrets are dev-only, and Production enforces that**: `ValidateProductionSecrets` in `Program.cs` refuses to start when `Jwt:SecretKey`, `Admin:Password`, or the MinIO secret still hold the checked-in defaults, or when `ImageSettings:ImageProxyKey`/`ImageProxySalt` are unset (unset means unsigned `/insecure` imgproxy URLs — an open resizer). Override them via environment variables; never commit a real secret to any appsettings file, and add any new secret's default to that guard.
28+
- **The checked-in `appsettings.json` secrets are dev-only, and Production enforces that**: `ValidateProductionSecrets` in `Program.cs` refuses to start when `Jwt:SecretKey`, `Admin:Password`, or the MinIO secret still equal what a fresh runtime read of the base `appsettings.json` holds, or when `ImageSettings:ImageProxyKey`/`ImageProxySalt` are unset (unset means unsigned `/insecure` imgproxy URLs — an open resizer). It deliberately compares against a **runtime file read**, not an embedded literal or hash (`ProductionSecretsGuard`/`ProductionSecretsSnapshot`) — a hard-coded copy of the dev secret, plaintext or hashed, is itself what SonarCloud's secret-detection rule flags. Override real deployments via environment variables; never commit a real secret to any appsettings file, and never add a new secret's literal/hash to the guard — extend the snapshot and the baseline file read instead.
2929
- **Anonymous endpoints doing per-request crypto or DB work need a rate limiter.** Login/refresh/register carry `.RequireRateLimiting(RateLimitPolicies.Auth)` (per-client-IP fixed window, configured via `RateLimiting:Auth`; the integration-test factory raises the limit). Client IPs are real only because `UseForwardedHeaders` trusts the nginx chain in `Program.cs` — keep that ordering (first in the pipeline) intact.
Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,48 @@
1-
using System.Security.Cryptography;
2-
using System.Text;
3-
41
namespace Anything.Application.Configuration;
52

63
/// <summary>
7-
/// Detects checked-in dev-default secrets so Program.cs can refuse to start
8-
/// Production on them. Pure (no host or options plumbing) so every branch is
9-
/// unit-testable. The defaults are recognized by SHA-256 hash — the plaintexts
10-
/// live only in appsettings.json, so this source carries no credential strings.
11-
/// Add any new secret's dev-default hash here.
4+
/// The subset of appsettings values Program.cs's startup guard cares about.
5+
/// Deliberately holds no literal secret text — see <see cref="ProductionSecretsGuard"/>.
6+
/// </summary>
7+
public sealed record ProductionSecretsSnapshot(
8+
string? JwtSecretKey,
9+
string? AdminPassword,
10+
string? MinioSecretKey,
11+
string? ImageProxyKey,
12+
string? ImageProxySalt);
13+
14+
/// <summary>
15+
/// Detects secrets that were never overridden from the checked-in
16+
/// appsettings.json defaults, so Program.cs can refuse to start Production on
17+
/// them. Deliberately takes two snapshots (the bound configuration, and a
18+
/// fresh read of the base appsettings.json — see Program.cs) rather than
19+
/// comparing against an embedded literal: a hard-coded copy of the dev secret
20+
/// (plaintext or hashed) is itself what static analysis flags as a
21+
/// "hard-coded secret", so the only value this class ever holds is whatever
22+
/// the deployment's own config file contains at runtime.
1223
/// </summary>
1324
public static class ProductionSecretsGuard
1425
{
15-
// SHA-256 (uppercase hex) of the dev defaults in appsettings.json.
16-
private const string DevJwtSecretKeySha256 = "360600F0C7D3CD42A71DF0136A2514E2B41B42C83C95209CE71BA7AEF13F1E87";
17-
private const string DevAdminPasswordSha256 = "3EB3FE66B31E3B4D10FA70B5CAD49C7112294AF6AE4E476A1C405155D45AA121";
18-
private const string DevMinioSecretKeySha256 = "AD9858116E63B0C5A4D7DC7F50F034C7247E56838DAE22C1832712FFDE48E694";
19-
20-
public static IReadOnlyList<string> FindDevDefaults(JwtSettings jwt, AdminSettings admin, ImageSettings images)
26+
public static IReadOnlyList<string> FindDevDefaults(
27+
ProductionSecretsSnapshot configured, ProductionSecretsSnapshot baseline)
2128
{
2229
var errors = new List<string>();
2330

24-
if (IsDevDefault(jwt.SecretKey, DevJwtSecretKeySha256))
25-
errors.Add("Jwt:SecretKey is the checked-in dev default — set a real secret via environment variables.");
31+
if (UnchangedFromBaseline(configured.JwtSecretKey, baseline.JwtSecretKey))
32+
errors.Add("Jwt:SecretKey is unchanged from the checked-in appsettings.json default — set a real secret via environment variables.");
2633

27-
if (IsDevDefault(admin.Password, DevAdminPasswordSha256))
28-
errors.Add("Admin:Password is the checked-in dev default — set a real password via environment variables.");
34+
if (UnchangedFromBaseline(configured.AdminPassword, baseline.AdminPassword))
35+
errors.Add("Admin:Password is unchanged from the checked-in appsettings.json default — set a real password via environment variables.");
2936

30-
if (IsDevDefault(images.SecretKey, DevMinioSecretKeySha256))
31-
errors.Add("ImageSettings:SecretKey is the checked-in dev default — set real MinIO credentials via environment variables.");
37+
if (UnchangedFromBaseline(configured.MinioSecretKey, baseline.MinioSecretKey))
38+
errors.Add("ImageSettings:SecretKey is unchanged from the checked-in appsettings.json default — set real MinIO credentials via environment variables.");
3239

33-
if (string.IsNullOrEmpty(images.ImageProxyKey) || string.IsNullOrEmpty(images.ImageProxySalt))
40+
if (string.IsNullOrEmpty(configured.ImageProxyKey) || string.IsNullOrEmpty(configured.ImageProxySalt))
3441
errors.Add("ImageSettings:ImageProxyKey/ImageProxySalt are unset — without them image URLs are unsigned (/insecure), an open resizer. Set both (hex) and configure imgproxy with the same IMGPROXY_KEY/IMGPROXY_SALT.");
3542

3643
return errors;
3744
}
3845

39-
private static bool IsDevDefault(string? configuredValue, string devDefaultSha256) =>
40-
configuredValue is not null
41-
&& Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(configuredValue))) == devDefaultSha256;
46+
private static bool UnchangedFromBaseline(string? configuredValue, string? baselineValue) =>
47+
!string.IsNullOrEmpty(configuredValue) && configuredValue == baselineValue;
4248
}

tests/Anything.Application.UnitTests/Configuration/ProductionSecretsGuardTests.cs

Lines changed: 55 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,99 +5,96 @@ namespace Anything.Application.UnitTests.Configuration;
55

66
public class ProductionSecretsGuardTests
77
{
8-
// The dev defaults checked into appsettings.json, in the order
9-
// Jwt:SecretKey, Admin:Password, ImageSettings:SecretKey. The guard stores
10-
// only their SHA-256 hashes, so these tests prove the plaintexts are
11-
// still recognized.
12-
private static readonly string[] AppsettingsDevDefaults =
13-
[
14-
"your-secret-key-min-32-characters-long-change-in-production",
15-
"Admin123!",
16-
"minioadmin"
17-
];
18-
19-
private static JwtSettings Jwt(string configured = "a-real-value-with-enough-length") => new()
20-
{
21-
SecretKey = configured,
22-
Issuer = "issuer",
23-
Audience = "audience"
24-
};
25-
26-
private static AdminSettings Admin(string? configured = "a-real-value") => new()
27-
{
28-
Email = "admin@example.com",
29-
Password = configured
30-
};
31-
32-
private static ImageSettings Images(
33-
string configuredSecret = "a-real-minio-value",
34-
string? proxyKey = "aabbcc",
35-
string? proxySalt = "ddeeff") => new()
36-
{
37-
BucketName = "bucket",
38-
Endpoint = "http://minio:9000",
39-
AccessKey = "access",
40-
SecretKey = configuredSecret,
41-
MinioSourceEndpoint = "http://minio:9000",
42-
ImageProxyBaseUrl = "http://imgproxy:8080",
43-
ImageProxyKey = proxyKey,
44-
ImageProxySalt = proxySalt
45-
};
8+
// Stands in for whatever the checked-in appsettings.json currently holds —
9+
// the guard only cares whether the configured value still matches it, not
10+
// what the value actually is.
11+
private static ProductionSecretsSnapshot Baseline() => new(
12+
JwtSecretKey: "baseline-jwt-value",
13+
AdminPassword: "baseline-admin-value",
14+
MinioSecretKey: "baseline-minio-value",
15+
ImageProxyKey: null,
16+
ImageProxySalt: null);
17+
18+
private static ProductionSecretsSnapshot OverriddenValues() => new(
19+
JwtSecretKey: "overridden-jwt-value",
20+
AdminPassword: "overridden-admin-value",
21+
MinioSecretKey: "overridden-minio-value",
22+
ImageProxyKey: "aabbcc",
23+
ImageProxySalt: "ddeeff");
4624

4725
[Fact]
48-
public void FindDevDefaults_WithRealSecrets_ReturnsNoErrors() =>
49-
Assert.Empty(ProductionSecretsGuard.FindDevDefaults(Jwt(), Admin(), Images()));
26+
public void FindDevDefaults_WithOverriddenValues_ReturnsNoErrors() =>
27+
Assert.Empty(ProductionSecretsGuard.FindDevDefaults(OverriddenValues(), Baseline()));
5028

5129
[Fact]
52-
public void FindDevDefaults_WithDefaultJwtSecret_FlagsIt()
30+
public void FindDevDefaults_WithJwtSecretUnchangedFromBaseline_FlagsIt()
5331
{
54-
var errors = ProductionSecretsGuard.FindDevDefaults(
55-
Jwt(AppsettingsDevDefaults[0]), Admin(), Images());
32+
var configured = OverriddenValues() with { JwtSecretKey = Baseline().JwtSecretKey };
33+
34+
var errors = ProductionSecretsGuard.FindDevDefaults(configured, Baseline());
5635

5736
Assert.Contains(errors, e => e.Contains("Jwt:SecretKey"));
5837
}
5938

6039
[Fact]
61-
public void FindDevDefaults_WithDefaultAdminPassword_FlagsIt()
40+
public void FindDevDefaults_WithAdminPasswordUnchangedFromBaseline_FlagsIt()
6241
{
63-
var errors = ProductionSecretsGuard.FindDevDefaults(
64-
Jwt(), Admin(AppsettingsDevDefaults[1]), Images());
42+
var configured = OverriddenValues() with { AdminPassword = Baseline().AdminPassword };
43+
44+
var errors = ProductionSecretsGuard.FindDevDefaults(configured, Baseline());
6545

6646
Assert.Contains(errors, e => e.Contains("Admin:Password"));
6747
}
6848

6949
[Fact]
70-
public void FindDevDefaults_WithDefaultMinioSecret_FlagsIt()
50+
public void FindDevDefaults_WithMinioSecretUnchangedFromBaseline_FlagsIt()
7151
{
72-
var errors = ProductionSecretsGuard.FindDevDefaults(
73-
Jwt(), Admin(), Images(configuredSecret: AppsettingsDevDefaults[2]));
52+
var configured = OverriddenValues() with { MinioSecretKey = Baseline().MinioSecretKey };
53+
54+
var errors = ProductionSecretsGuard.FindDevDefaults(configured, Baseline());
7455

7556
Assert.Contains(errors, e => e.Contains("ImageSettings:SecretKey"));
7657
}
7758

7859
[Fact]
79-
public void FindDevDefaults_WithUnsetAdminPassword_DoesNotFlagIt() =>
80-
Assert.Empty(ProductionSecretsGuard.FindDevDefaults(Jwt(), Admin(configured: null), Images()));
60+
public void FindDevDefaults_WithUnsetAdminPassword_DoesNotFlagIt()
61+
{
62+
var configured = OverriddenValues() with { AdminPassword = null };
63+
64+
var errors = ProductionSecretsGuard.FindDevDefaults(configured, Baseline());
65+
66+
Assert.DoesNotContain(errors, e => e.Contains("Admin:Password"));
67+
}
68+
69+
[Fact]
70+
public void FindDevDefaults_WithNoBaselineToCompareAgainst_DoesNotFlagRealValues()
71+
{
72+
// appsettings.json missing at runtime (see Program.cs's `optional: true`) —
73+
// nothing to compare against, so a real configured secret isn't flagged.
74+
var missingBaseline = new ProductionSecretsSnapshot(null, null, null, "aabbcc", "ddeeff");
75+
76+
Assert.Empty(ProductionSecretsGuard.FindDevDefaults(OverriddenValues(), missingBaseline));
77+
}
8178

8279
[Theory]
8380
[InlineData(null, "ddeeff")]
8481
[InlineData("aabbcc", null)]
8582
[InlineData("", "")]
8683
public void FindDevDefaults_WithMissingImgproxyKeys_FlagsIt(string? proxyKey, string? proxySalt)
8784
{
88-
var errors = ProductionSecretsGuard.FindDevDefaults(
89-
Jwt(), Admin(), Images(proxyKey: proxyKey, proxySalt: proxySalt));
85+
var configured = OverriddenValues() with { ImageProxyKey = proxyKey, ImageProxySalt = proxySalt };
86+
87+
var errors = ProductionSecretsGuard.FindDevDefaults(configured, Baseline());
9088

9189
Assert.Contains(errors, e => e.Contains("ImageProxyKey"));
9290
}
9391

9492
[Fact]
95-
public void FindDevDefaults_WithEveryDefault_ReturnsAllErrors()
93+
public void FindDevDefaults_WithEveryValueUnchanged_ReturnsAllErrors()
9694
{
97-
var errors = ProductionSecretsGuard.FindDevDefaults(
98-
Jwt(AppsettingsDevDefaults[0]),
99-
Admin(AppsettingsDevDefaults[1]),
100-
Images(configuredSecret: AppsettingsDevDefaults[2], proxyKey: null, proxySalt: null));
95+
var configured = Baseline() with { ImageProxyKey = null, ImageProxySalt = null };
96+
97+
var errors = ProductionSecretsGuard.FindDevDefaults(configured, Baseline());
10198

10299
Assert.Equal(4, errors.Count);
103100
}

0 commit comments

Comments
 (0)