46cb6965ac
Security-sensitive batch, handled main-thread for careful judgment on secret-leak and pepper-bypass paths. Secret leak / pepper bypass: - CD-016 (pepper bypass): InboundApiRepository's GetApiKeyByValueAsync no longer hashes the candidate with the unpeppered ApiKeyHasher.Default — ctor takes a lazy Func<IApiKeyHasher> accessor (lazy so test composition roots without a pepper still bring up the repository), and the DI registration wires sp.GetService<IApiKeyHasher>() so the production peppered hasher matches the stored KeyHash. Regression test asserts positive (peppered roundtrip) AND negative (Default hasher misses the same key — proving the lookup uses the injected hasher). - MgmtSvc-020 (SMTP credential leak): UpdateSmtpConfig/ListSmtpConfigs now project through SmtpConfigPublicShape so the response payload and audit-row afterState never carry the Credentials field — only a HasCredentials bool. The SMTP password / OAuth2 client secret no longer leaves the Admin-only UpdateSmtpConfig boundary the caller already supplied it to. Redaction: - AuditLog-008 (test-fixture under-redact): new SafeDefaultAuditPayloadFilter (stateless singleton) does HTTP header redaction for the always-sensitive defaults (Authorization, X-Api-Key, Cookie, Set-Cookie). FallbackAuditWriter, CentralAuditWriter, and AuditLogIngestActor (both ingest paths) default to it instead of null — composition roots that bypass AddAuditLog can no longer write unredacted auth headers to the audit store. - NotifService-025 (over-mask): CredentialRedactor.Scrub now only masks the last colon-separated component (password / clientSecret) AND only if it's >= 12 chars (typical password heuristic). Short user names like "root" no longer become global redaction tokens that eat unrelated diagnostic text. The full packed string is always masked regardless of length. 3 new negative tests pin the no-over-mask contract. Audit-row correctness / fail-loud: - InboundAPI-025: Program.cs UseWhen predicate now excludes /api/audit, /api/management, /api/centralui, /api/script-analysis AND requires POST — the AuditWriteMiddleware no longer emits spurious ApiInbound rows for audit-log query/export endpoints (write-on-read recursion broken). - ESG-021: ApplyAuth now logs Warning (not silent) on empty AuthConfiguration for apikey/basic, unknown AuthType, and malformed Basic config. AuthConfiguration value NEVER logged. AuthType=none remains silent (documented unauthenticated sentinel). - Security-021: AddSecurity now logs a startup Warning when RequireHttpsCookie=false — an HTTP-only deployment that previously transmitted the cookie-embedded JWT silently in cleartext is now audible in the log. Defensive: - CD-021: SwitchOutPartitionAsync's monthBoundary format string now yyyy-MM-dd HH:mm:ss.fffffff (datetime2(7) precision) so a future sub-second / non-midnight boundary doesn't silently round to the wrong partition. Plus reconciled stale per-module Open-findings counters that had drifted from earlier sessions (AuditLog, CD, ESG, IAPI, MgmtSvc, NotifService, Security). Build clean; all affected test projects green (Host 208, ConfigDB 242, ESG 69, IAPI 151, MgmtSvc 100, NotifService 55, Security 85, AuditLog 247/248 — 1 pre-existing date-sensitive integration test flake on PartitionPurgeTests, unrelated). README regenerated: 46 open (was 54).
77 lines
2.8 KiB
C#
77 lines
2.8 KiB
C#
namespace ScadaLink.NotificationService.Tests;
|
|
|
|
/// <summary>
|
|
/// NS-009: Tests for scrubbing SMTP credential secrets out of log/result text.
|
|
/// </summary>
|
|
public class CredentialRedactorTests
|
|
{
|
|
[Fact]
|
|
public void Scrub_BasicAuthPassword_IsMasked()
|
|
{
|
|
// Password 'Hunter2pass!word' is 16 chars (>= MinSecretLength=12) and
|
|
// therefore qualifies as a redactable secret-shaped trailing component.
|
|
var text = "535 5.7.8 Authentication failed for user 'svc' with password 'Hunter2pass!word'";
|
|
var result = CredentialRedactor.Scrub(text, "svc:Hunter2pass!word");
|
|
|
|
Assert.DoesNotContain("Hunter2pass!word", result);
|
|
Assert.DoesNotContain("svc:Hunter2pass!word", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scrub_OAuth2ClientSecret_IsMasked()
|
|
{
|
|
var text = "Token request failed: client_secret=Sup3rSecretValue rejected by tenant";
|
|
var result = CredentialRedactor.Scrub(text, "tenant-guid:client-guid:Sup3rSecretValue");
|
|
|
|
Assert.DoesNotContain("Sup3rSecretValue", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scrub_NullCredentials_ReturnsTextUnchanged()
|
|
{
|
|
Assert.Equal("plain text", CredentialRedactor.Scrub("plain text", null));
|
|
}
|
|
|
|
[Fact]
|
|
public void Scrub_NullText_ReturnsEmpty()
|
|
{
|
|
Assert.Equal(string.Empty, CredentialRedactor.Scrub(null, "user:pass"));
|
|
}
|
|
|
|
// --- NS-025: don't over-mask short non-secret components ---
|
|
|
|
[Fact]
|
|
public void Scrub_ShortUserName_IsNotMaskedOutsidePackedString()
|
|
{
|
|
// 'root' is the Basic Auth user name — short, common, and absolutely
|
|
// not a secret. It must NOT be masked when it appears in unrelated
|
|
// diagnostic text like a file path.
|
|
var text = "Config file at /root/.config/scada.conf was not found.";
|
|
var result = CredentialRedactor.Scrub(text, "root:hunter2longenoughpwd");
|
|
|
|
Assert.Contains("/root/.config", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scrub_TenantId_IsNotMaskedOutsidePackedString()
|
|
{
|
|
// The tenant id is not secret — only the client secret is. A tenant id
|
|
// appearing in unrelated text (e.g. an error-code suffix) must survive.
|
|
var text = "Error code tnt-1234567890-abcd reported by upstream";
|
|
var result = CredentialRedactor.Scrub(text, "tnt-1234567890-abcd:cli-guid:RealClientSecretLongEnough");
|
|
|
|
Assert.Contains("tnt-1234567890-abcd", result);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scrub_FullPackedCredential_IsAlwaysMaskedRegardlessOfLength()
|
|
{
|
|
// Even a short packed string must be masked when it appears verbatim —
|
|
// that exact appearance can only come from the credential itself.
|
|
var text = "Auth bundle was rejected: u:p";
|
|
var result = CredentialRedactor.Scrub(text, "u:p");
|
|
|
|
Assert.DoesNotContain("u:p", result);
|
|
}
|
|
}
|