fix(security): close Theme 7 — 8 secrets / redaction / append-only findings
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).
This commit is contained in:
@@ -164,8 +164,12 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// is a silent no-op.
|
||||
// Filter BEFORE the IngestedAtUtc stamp so the redacted
|
||||
// copy carries the central-side ingest timestamp. Filter
|
||||
// is contract-bound to never throw; null = pass-through.
|
||||
var filtered = filter?.Apply(evt) ?? evt;
|
||||
// is contract-bound to never throw. AuditLog-008: a null
|
||||
// filter (test composition root, no IAuditPayloadFilter
|
||||
// registered) now falls back to the SafeDefault rather than
|
||||
// pass-through, so HTTP header redaction always runs.
|
||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
var filtered = safeFilter.Apply(evt);
|
||||
var ingested = filtered with { IngestedAtUtc = nowUtc };
|
||||
await repository.InsertIfNotExistsAsync(ingested).ConfigureAwait(false);
|
||||
accepted.Add(evt.EventId);
|
||||
@@ -239,8 +243,10 @@ public class AuditLogIngestActor : ReceiveActor
|
||||
// Filter the audit half BEFORE the dual-write — only the
|
||||
// AuditLog row's payload columns are filterable; SiteCalls
|
||||
// carries operational state only (status, retry count) and
|
||||
// is left untouched.
|
||||
var filteredAudit = filter?.Apply(entry.Audit) ?? entry.Audit;
|
||||
// is left untouched. AuditLog-008: null filter falls back
|
||||
// to SafeDefault so header redaction always runs.
|
||||
var safeFilter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
var filteredAudit = safeFilter.Apply(entry.Audit);
|
||||
var auditStamped = filteredAudit with { IngestedAtUtc = ingestedAt };
|
||||
var siteCallStamped = entry.SiteCall with { IngestedAtUtc = ingestedAt };
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<CentralAuditWriter> _logger;
|
||||
private readonly IAuditPayloadFilter? _filter;
|
||||
private readonly IAuditPayloadFilter _filter;
|
||||
private readonly ICentralAuditWriteFailureCounter _failureCounter;
|
||||
private readonly INodeIdentityProvider? _nodeIdentity;
|
||||
|
||||
@@ -80,7 +80,12 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
{
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_filter = filter;
|
||||
// AuditLog-008: never default to null — over-redact instead.
|
||||
// SafeDefaultAuditPayloadFilter applies HTTP header redaction with
|
||||
// hard-coded sensitive defaults so a composition root that omits the
|
||||
// real filter still scrubs Authorization / X-Api-Key / Cookie /
|
||||
// Set-Cookie before persistence.
|
||||
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
_failureCounter = failureCounter ?? new NoOpCentralAuditWriteFailureCounter();
|
||||
_nodeIdentity = nodeIdentity;
|
||||
}
|
||||
@@ -99,9 +104,11 @@ public sealed class CentralAuditWriter : ICentralAuditWriter
|
||||
try
|
||||
{
|
||||
// Filter BEFORE stamping IngestedAtUtc + handing to the repo. The
|
||||
// filter contract is "never throws"; the null-coalesce keeps the
|
||||
// M4 test composition roots (no filter passed) working unchanged.
|
||||
var filtered = _filter?.Apply(evt) ?? evt;
|
||||
// filter contract is "never throws". AuditLog-008: _filter is now
|
||||
// non-null (SafeDefaultAuditPayloadFilter fallback) so header
|
||||
// redaction always runs even in composition roots that omit the
|
||||
// real filter.
|
||||
var filtered = _filter.Apply(evt);
|
||||
|
||||
// SourceNode-stamping (Task 12): caller-provided value wins
|
||||
// (supports any future direct-write callsite that already has its
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
|
||||
namespace ScadaLink.AuditLog.Payload;
|
||||
|
||||
/// <summary>
|
||||
/// AuditLog-008: minimal always-safe fallback filter used by the writer chain
|
||||
/// when no <see cref="IAuditPayloadFilter"/> is injected (test composition
|
||||
/// roots, future composition roots that bypass <c>AddAuditLog</c>). Performs
|
||||
/// HTTP header redaction for the always-sensitive defaults
|
||||
/// (Authorization, X-Api-Key, Cookie, Set-Cookie) so a fixture that wires a
|
||||
/// real <see cref="AuditEvent.RequestSummary"/> never persists those headers
|
||||
/// in cleartext. Does NOT perform body-regex redaction, SQL-parameter
|
||||
/// redaction, or truncation — those stages need
|
||||
/// <see cref="DefaultAuditPayloadFilter"/> with live options. The contract is:
|
||||
/// over-redact safely, never throw, never miss a header that's on the
|
||||
/// default sensitive list.
|
||||
/// </summary>
|
||||
public sealed class SafeDefaultAuditPayloadFilter : IAuditPayloadFilter
|
||||
{
|
||||
/// <summary>Singleton instance — the filter is stateless and side-effect-free.</summary>
|
||||
public static SafeDefaultAuditPayloadFilter Instance { get; } = new SafeDefaultAuditPayloadFilter();
|
||||
|
||||
private static readonly string[] DefaultHeaderRedactList =
|
||||
{
|
||||
"Authorization",
|
||||
"X-Api-Key",
|
||||
"Cookie",
|
||||
"Set-Cookie",
|
||||
};
|
||||
|
||||
private static readonly Regex HeaderRegex = new(
|
||||
@"(?<name>[A-Za-z][A-Za-z0-9\-_]*)\s*:\s*(?<value>[^\r\n]*)",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private SafeDefaultAuditPayloadFilter() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public AuditEvent Apply(AuditEvent rawEvent)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rawEvent);
|
||||
try
|
||||
{
|
||||
return rawEvent with
|
||||
{
|
||||
RequestSummary = RedactHeaders(rawEvent.RequestSummary),
|
||||
ResponseSummary = RedactHeaders(rawEvent.ResponseSummary),
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Over-redact: drop both summaries entirely so a malformed parse
|
||||
// path never leaks the original. The contract is "never throw."
|
||||
return rawEvent with
|
||||
{
|
||||
RequestSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
|
||||
ResponseSummary = "[redacted by SafeDefaultAuditPayloadFilter]",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static string? RedactHeaders(string? summary)
|
||||
{
|
||||
if (string.IsNullOrEmpty(summary)) return summary;
|
||||
|
||||
return HeaderRegex.Replace(summary, m =>
|
||||
{
|
||||
var name = m.Groups["name"].Value;
|
||||
foreach (var sensitive in DefaultHeaderRedactList)
|
||||
{
|
||||
if (string.Equals(name, sensitive, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"{name}: [REDACTED]";
|
||||
}
|
||||
}
|
||||
return m.Value;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
||||
private readonly RingBufferFallback _ring;
|
||||
private readonly IAuditWriteFailureCounter _failureCounter;
|
||||
private readonly ILogger<FallbackAuditWriter> _logger;
|
||||
private readonly IAuditPayloadFilter? _filter;
|
||||
private readonly IAuditPayloadFilter _filter;
|
||||
private readonly SemaphoreSlim _drainGate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
@@ -60,7 +60,14 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
||||
_ring = ring ?? throw new ArgumentNullException(nameof(ring));
|
||||
_failureCounter = failureCounter ?? throw new ArgumentNullException(nameof(failureCounter));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_filter = filter; // null = no-op pass-through; see WriteAsync.
|
||||
// AuditLog-008: never default to a null filter — over-redact instead.
|
||||
// SafeDefaultAuditPayloadFilter.Instance performs HTTP header
|
||||
// redaction with the hard-coded sensitive defaults (Authorization,
|
||||
// X-Api-Key, Cookie, Set-Cookie) so a test composition root that
|
||||
// doesn't bind the real options never persists those headers
|
||||
// verbatim. The real DefaultAuditPayloadFilter (truncation + body /
|
||||
// SQL-param redaction) is wired by AddAuditLog and takes precedence.
|
||||
_filter = filter ?? Payload.SafeDefaultAuditPayloadFilter.Instance;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,9 +79,10 @@ public sealed class FallbackAuditWriter : IAuditWriter
|
||||
// and (on failure) to the ring buffer — so a primary outage that
|
||||
// drains later still hands the SqliteAuditWriter a row that has
|
||||
// already been truncated and redacted. The filter contract is
|
||||
// "MUST NOT throw"; the null-coalesce keeps test composition roots
|
||||
// that don't wire a filter working unchanged.
|
||||
var filtered = _filter?.Apply(evt) ?? evt;
|
||||
// "MUST NOT throw". AuditLog-008: _filter is now non-null (defaults
|
||||
// to SafeDefaultAuditPayloadFilter so header redaction is always
|
||||
// applied even in composition roots that don't wire the real filter).
|
||||
var filtered = _filter.Apply(evt);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -198,8 +198,12 @@ VALUES
|
||||
|
||||
// ISO 8601 in UTC — SQL Server's datetime2 literal parser accepts this
|
||||
// unambiguously and the value is round-trip-safe across SET DATEFORMAT
|
||||
// settings.
|
||||
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss");
|
||||
// settings. CD-021: use datetime2(7) precision (.fffffff) so a future
|
||||
// non-midnight or sub-second boundary doesn't silently round to the
|
||||
// wrong partition (today the migration only seeds at T00:00:00 exactly,
|
||||
// but the format string is on the boundary value's own contract — match
|
||||
// it to the column precision rather than to the current seed pattern).
|
||||
var monthBoundaryStr = monthBoundary.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
|
||||
|
||||
// Two-statement batch: the first SELECT samples the per-partition row
|
||||
// count BEFORE the dance so we can report it back to the purge actor;
|
||||
|
||||
@@ -10,16 +10,38 @@ namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
public class InboundApiRepository : IInboundApiRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
// CD-016: lazily resolved so the InboundAPI ApiKeyHasher factory (which throws
|
||||
// when no pepper is configured) is only invoked if GetApiKeyByValueAsync is
|
||||
// actually called — Central/Host startup composition roots that never call
|
||||
// this method (the production ApiKeyValidator deliberately doesn't) get to
|
||||
// bring InboundApiRepository up without forcing every test to wire a
|
||||
// throw-away pepper into InboundApiOptions.
|
||||
private readonly Func<IApiKeyHasher> _hasherAccessor;
|
||||
private readonly ILogger<InboundApiRepository> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the InboundApiRepository class.
|
||||
/// </summary>
|
||||
/// <param name="context">The database context for accessing inbound API data.</param>
|
||||
/// <param name="hasherAccessor">
|
||||
/// CD-016: factory that returns the API-key hasher used to digest a candidate
|
||||
/// plaintext for the peppered <see cref="GetApiKeyByValueAsync"/> lookup.
|
||||
/// Resolution is deferred to first call so a composition root that doesn't
|
||||
/// register <see cref="IApiKeyHasher"/> (or whose factory would throw because
|
||||
/// no pepper is configured) can still bring up the repository for callers that
|
||||
/// don't touch the value-lookup path. Defaults to a factory returning
|
||||
/// <see cref="ApiKeyHasher.Default"/>; production wires
|
||||
/// <c>sp => sp.GetRequiredService<IApiKeyHasher>()</c> via DI so the
|
||||
/// lookup uses the same peppered digest as the production write path.
|
||||
/// </param>
|
||||
/// <param name="logger">Optional logger instance for warnings and diagnostics.</param>
|
||||
public InboundApiRepository(ScadaLinkDbContext context, ILogger<InboundApiRepository>? logger = null)
|
||||
public InboundApiRepository(
|
||||
ScadaLinkDbContext context,
|
||||
Func<IApiKeyHasher>? hasherAccessor = null,
|
||||
ILogger<InboundApiRepository>? logger = null)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_hasherAccessor = hasherAccessor ?? (() => ApiKeyHasher.Default);
|
||||
_logger = logger ?? NullLogger<InboundApiRepository>.Instance;
|
||||
}
|
||||
|
||||
@@ -34,7 +56,13 @@ public class InboundApiRepository : IInboundApiRepository
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var keyHash = ApiKeyHasher.Default.Hash(keyValue);
|
||||
// CD-016: hash the candidate with the DI-provided peppered hasher so this
|
||||
// lookup matches keys whose stored KeyHash was produced by the production
|
||||
// ApiKeyHasher(pepper). The pre-fix call to ApiKeyHasher.Default would
|
||||
// silently return null for every real key on any peppered deployment.
|
||||
// Resolution is deferred until this method is actually called so the
|
||||
// pepper-validating factory doesn't fire during startup composition.
|
||||
var keyHash = _hasherAccessor().Hash(keyValue);
|
||||
return await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyHash == keyHash, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
@@ -53,7 +54,15 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<INotificationOutboxRepository, NotificationOutboxRepository>();
|
||||
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
||||
services.AddScoped<ISiteCallAuditRepository, SiteCallAuditRepository>();
|
||||
services.AddScoped<IInboundApiRepository, InboundApiRepository>();
|
||||
// CD-016: factory registration wires a lazy accessor for IApiKeyHasher so
|
||||
// the production peppered hasher is used (via DI) when GetApiKeyByValueAsync
|
||||
// is actually called, but composition roots that never call it (and may
|
||||
// not register IApiKeyHasher at all) still bring up the repository.
|
||||
services.AddScoped<IInboundApiRepository>(sp => new InboundApiRepository(
|
||||
sp.GetRequiredService<ScadaLinkDbContext>(),
|
||||
hasherAccessor: () => sp.GetService<Commons.Types.InboundApi.IApiKeyHasher>()
|
||||
?? Commons.Types.InboundApi.ApiKeyHasher.Default,
|
||||
logger: sp.GetService<ILogger<InboundApiRepository>>()));
|
||||
services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
|
||||
services.AddScoped<IAuditService, AuditService>();
|
||||
services.AddScoped<IInstanceLocator, InstanceLocator>();
|
||||
|
||||
@@ -464,12 +464,29 @@ public class ExternalSystemClient : IExternalSystemClient
|
||||
return url;
|
||||
}
|
||||
|
||||
private static void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
|
||||
private void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
|
||||
{
|
||||
if (string.IsNullOrEmpty(system.AuthConfiguration))
|
||||
return;
|
||||
// ESG-021: distinguish "intentionally unauthenticated" (AuthType = none)
|
||||
// from "AuthConfiguration is missing or empty for a type that requires it"
|
||||
// (deployment glitch, decryption failure, operator typo). The unauthenticated
|
||||
// case is silent; the requires-creds-but-empty case logs a Warning so an
|
||||
// operator debugging a recurring 401 sees the cause inside ScadaLink instead
|
||||
// of having to read the remote system's logs. The value of AuthConfiguration
|
||||
// is NEVER logged.
|
||||
var authType = system.AuthType?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
|
||||
switch (system.AuthType.ToLowerInvariant())
|
||||
if (string.IsNullOrEmpty(system.AuthConfiguration))
|
||||
{
|
||||
if (authType is "apikey" or "basic")
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"ApplyAuth: External system '{System}' has AuthType '{AuthType}' but AuthConfiguration is empty; request will be sent without an auth header.",
|
||||
system.Name, system.AuthType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (authType)
|
||||
{
|
||||
case "apikey":
|
||||
// Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
|
||||
@@ -493,6 +510,26 @@ public class ExternalSystemClient : IExternalSystemClient
|
||||
Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
|
||||
}
|
||||
else
|
||||
{
|
||||
// ESG-021: malformed Basic config (no ':' separator) means the
|
||||
// request goes out with no Authorization header. Warn so the
|
||||
// failure mode is visible inside ScadaLink.
|
||||
_logger.LogWarning(
|
||||
"ApplyAuth: External system '{System}' AuthType 'basic' AuthConfiguration is malformed (expected 'username:password'); request will be sent without an Authorization header.",
|
||||
system.Name);
|
||||
}
|
||||
break;
|
||||
|
||||
case "none":
|
||||
// Documented sentinel for unauthenticated systems — silent by design.
|
||||
break;
|
||||
|
||||
default:
|
||||
// ESG-021: unknown AuthType silently fell through here before. Warn.
|
||||
_logger.LogWarning(
|
||||
"ApplyAuth: External system '{System}' has unknown AuthType '{AuthType}'; request will be sent without an auth header. Allowed values: apikey, basic, none.",
|
||||
system.Name, system.AuthType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +195,23 @@ try
|
||||
// is responsible for stashing the resolved API key name on
|
||||
// HttpContext.Items (see AuditWriteMiddleware.AuditActorItemKey) AFTER its
|
||||
// in-handler API key validation succeeds.
|
||||
// InboundAPI-025: scope the audit middleware to the inbound API method
|
||||
// route (/api/{methodName}) and explicitly exclude the management/audit
|
||||
// sub-trees that share the /api prefix. Without these exclusions the
|
||||
// middleware would emit a spurious ApiInbound audit row for every
|
||||
// /api/audit/query and /api/audit/export call (and would treat audit-log
|
||||
// reads as inbound script invocations — recursive write-on-read). The
|
||||
// POST-only filter rules out the GET routes on /api/audit, /api/centralui,
|
||||
// /api/script-analysis even if a future route is added under those
|
||||
// prefixes with the same verb; the explicit prefix excludes still belt-
|
||||
// and-brace POST-y additions there.
|
||||
app.UseWhen(
|
||||
ctx => ctx.Request.Path.StartsWithSegments("/api"),
|
||||
ctx => ctx.Request.Path.StartsWithSegments("/api")
|
||||
&& !ctx.Request.Path.StartsWithSegments("/api/audit")
|
||||
&& !ctx.Request.Path.StartsWithSegments("/api/centralui")
|
||||
&& !ctx.Request.Path.StartsWithSegments("/api/script-analysis")
|
||||
&& !ctx.Request.Path.StartsWithSegments("/api/management")
|
||||
&& HttpMethods.IsPost(ctx.Request.Method),
|
||||
branch => branch.UseAuditWriteMiddleware());
|
||||
|
||||
// WP-12: Map readiness endpoint — returns 503 until ready, 200 when ready.
|
||||
|
||||
@@ -1135,10 +1135,36 @@ public class ManagementActor : ReceiveActor
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MgmtSvc-020: project an SmtpConfiguration to a credential-free shape so the
|
||||
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
|
||||
/// boundary via response payloads or audit afterState. Mirrors the
|
||||
/// ApiKey-projection pattern in HandleListApiKeys / CD-012's fix.
|
||||
/// </summary>
|
||||
private static object SmtpConfigPublicShape(Commons.Entities.Notifications.SmtpConfiguration c) =>
|
||||
new
|
||||
{
|
||||
c.Id,
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.AuthType,
|
||||
c.FromAddress,
|
||||
c.TlsMode,
|
||||
c.ConnectionTimeoutSeconds,
|
||||
c.MaxConcurrentConnections,
|
||||
c.MaxRetries,
|
||||
c.RetryDelay,
|
||||
HasCredentials = !string.IsNullOrEmpty(c.Credentials),
|
||||
};
|
||||
|
||||
private static async Task<object?> HandleListSmtpConfigs(IServiceProvider sp)
|
||||
{
|
||||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||||
return await repo.GetAllSmtpConfigurationsAsync();
|
||||
var configs = await repo.GetAllSmtpConfigurationsAsync();
|
||||
// MgmtSvc-020: project away the Credentials field — read access to this
|
||||
// list is broader than the Admin-only UpdateSmtpConfig path that owns
|
||||
// the secret.
|
||||
return configs.Select(SmtpConfigPublicShape).ToList();
|
||||
}
|
||||
|
||||
private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
|
||||
@@ -1156,8 +1182,12 @@ public class ManagementActor : ReceiveActor
|
||||
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
|
||||
await repo.UpdateSmtpConfigurationAsync(config);
|
||||
await repo.SaveChangesAsync();
|
||||
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config);
|
||||
return config;
|
||||
// MgmtSvc-020: audit the credential-free shape — the *fact of* the change
|
||||
// (and which non-secret fields hold) is observable; the secret value is
|
||||
// not persisted to the audit log where OperationalAuditRoles can read it.
|
||||
var publicShape = SmtpConfigPublicShape(config);
|
||||
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
|
||||
return publicShape;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
|
||||
@@ -24,6 +24,15 @@ public static class CredentialRedactor
|
||||
/// The credential string in use — Basic Auth <c>user:pass</c> or OAuth2
|
||||
/// <c>tenantId:clientId:clientSecret</c>. May be null.
|
||||
/// </param>
|
||||
/// <summary>
|
||||
/// NS-025: minimum length for a colon-separated SECRET component to be
|
||||
/// considered worth masking. Twelve characters is the standard heuristic
|
||||
/// for "long enough to be a password / client secret"; shorter components
|
||||
/// (e.g. a 4-char user name like <c>root</c>, or a 7-char "from" alias)
|
||||
/// would mask too much unrelated diagnostic text if treated as secrets.
|
||||
/// </summary>
|
||||
private const int MinSecretLength = 12;
|
||||
|
||||
public static string Scrub(string? text, string? credentials)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text) || string.IsNullOrEmpty(credentials))
|
||||
@@ -33,16 +42,36 @@ public static class CredentialRedactor
|
||||
|
||||
var result = text;
|
||||
|
||||
// Mask each individual colon-delimited component (covers user, password,
|
||||
// tenant, clientId, clientSecret) and the whole packed string. Order longest
|
||||
// first so a component that is a substring of another is still fully masked.
|
||||
var parts = credentials.Split(':')
|
||||
.Where(p => p.Length >= 4)
|
||||
.Append(credentials)
|
||||
.Distinct()
|
||||
.OrderByDescending(p => p.Length);
|
||||
// NS-025: redact only the obviously-secret slots — the LAST
|
||||
// colon-separated component (the password in Basic, the client
|
||||
// secret in OAuth2) and the whole packed string — not the user
|
||||
// name / tenant id / client id. A short user name like "root" or
|
||||
// a sender alias like "smtp" no longer becomes a global redaction
|
||||
// token that eats unrelated path / error text.
|
||||
var secretsToRedact = new List<string>();
|
||||
|
||||
foreach (var part in parts)
|
||||
// The full packed credential is always the most-sensitive shape.
|
||||
secretsToRedact.Add(credentials);
|
||||
|
||||
// The trailing colon-component is the password / clientSecret slot.
|
||||
// Only redact it if it's plausibly secret-shaped (>= MinSecretLength).
|
||||
var parts = credentials.Split(':');
|
||||
if (parts.Length >= 2)
|
||||
{
|
||||
var lastComponent = parts[^1];
|
||||
if (lastComponent.Length >= MinSecretLength)
|
||||
{
|
||||
secretsToRedact.Add(lastComponent);
|
||||
}
|
||||
}
|
||||
|
||||
// Order longest first so a secret that is a substring of the packed
|
||||
// string is still fully masked.
|
||||
var ordered = secretsToRedact
|
||||
.Distinct()
|
||||
.OrderByDescending(s => s.Length);
|
||||
|
||||
foreach (var part in ordered)
|
||||
{
|
||||
result = result.Replace(part, Mask, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.Security;
|
||||
@@ -56,7 +57,7 @@ public static class ServiceCollectionExtensions
|
||||
// session window. Bound here via PostConfigure so SecurityOptions
|
||||
// (configured by the Host after AddSecurity) is honoured.
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<SecurityOptions>>((cookieOptions, securityOptions) =>
|
||||
.Configure<IOptions<SecurityOptions>, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) =>
|
||||
{
|
||||
var idleMinutes = securityOptions.Value.IdleTimeoutMinutes;
|
||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(idleMinutes);
|
||||
@@ -69,6 +70,18 @@ public static class ServiceCollectionExtensions
|
||||
cookieOptions.Cookie.SecurePolicy = securityOptions.Value.RequireHttpsCookie
|
||||
? Microsoft.AspNetCore.Http.CookieSecurePolicy.Always
|
||||
: Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
|
||||
|
||||
// Security-021: when the operator opts out of HTTPS-only cookies,
|
||||
// log a Warning so an HTTP-only deployment is at least audible in
|
||||
// the startup log. The cookie carries the embedded JWT bearer
|
||||
// credential — over plain HTTP that travels in cleartext on every
|
||||
// request. The default is true; this branch fires only on an
|
||||
// explicit opt-out (typically the dev Docker cluster).
|
||||
if (!securityOptions.Value.RequireHttpsCookie)
|
||||
{
|
||||
loggerFactory.CreateLogger("ScadaLink.Security").LogWarning(
|
||||
"SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production.");
|
||||
}
|
||||
});
|
||||
|
||||
services.AddScadaLinkAuthorization();
|
||||
|
||||
Reference in New Issue
Block a user