fix(config): refuse to store a Sql credential in a node config override (#499)
`ClusterNode.DriverConfigOverridesJson` is the third surface a Sql driver's config is persisted on, and the only one nothing gated. It is a map keyed by `DriverInstanceId` merged onto the cluster-level `DriverConfig`, so a literal `connectionString` pasted there leaks exactly as one pasted into the driver — the leak #498 closed for `DriverInstance.DriverConfig` and `Device.DeviceConfig`. Gated at the SAVE, not at the deploy — a deliberate departure from both options the issue offered: - `DraftSnapshot` carries no `ClusterNode` rows, and adding them would widen the snapshot and every builder of one for a single rule about a value that never enters the artifact. - More to the point, a deploy gate is the wrong instrument here. Node overrides are not in the artifact, so blocking a deploy would not stop the credential being stored — it is already in the database and replicated by then. Refusing the save is the only point where "refuse to store it" is literally true, which is #498's own framing: discarding a secret on read is not the same as refusing to store it. The check moves into a shared `SqlCredentialGuard` so the two enforcement points cannot drift; `DraftValidator` now calls it instead of its own private helper. Kept deliberately narrow — the `Sql` driver's `connectionString` only, and only for instance ids that ARE Sql drivers. The issue asked whether to generalise to credential-shaped keys across every driver type; not doing that, for the same reason #498 did not: a broader sweep would start refusing configs that are legitimate today for drivers which never made an indirect-credential guarantee, which is a regression rather than defence in depth. Widen per driver, as each gains its own contract. The error names the offending driver instance(s) and NEVER the value — it reaches the AdminUI and the audit trail. 14 new cases cover both halves, including the case variants (System.Text.Json binds `ConnectionString` to `connectionString`, so a case variant is the same key, not a bypass), non-Sql ids being ignored, and every blank/malformed/non-object shape.
This commit is contained in:
@@ -60,6 +60,11 @@ public static class DraftValidator
|
|||||||
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
|
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
|
||||||
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
|
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
|
||||||
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
||||||
|
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
|
||||||
|
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
|
||||||
|
/// <see cref="DraftSnapshot"/> and is gated at its save instead (#499); see
|
||||||
|
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
|
||||||
|
/// never enters the artifact.</para>
|
||||||
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
||||||
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
|
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
|
||||||
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
|
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
|
||||||
@@ -74,7 +79,7 @@ public static class DraftValidator
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
||||||
{
|
{
|
||||||
const string ForbiddenKey = "connectionString";
|
const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
|
||||||
|
|
||||||
var sqlInstanceIds = draft.DriverInstances
|
var sqlInstanceIds = draft.DriverInstances
|
||||||
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
||||||
@@ -85,7 +90,7 @@ public static class DraftValidator
|
|||||||
foreach (var d in draft.DriverInstances)
|
foreach (var d in draft.DriverInstances)
|
||||||
{
|
{
|
||||||
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
||||||
if (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) continue;
|
if (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) continue;
|
||||||
errors.Add(new("SqlConnectionStringPersisted",
|
errors.Add(new("SqlConnectionStringPersisted",
|
||||||
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
||||||
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
||||||
@@ -98,7 +103,7 @@ public static class DraftValidator
|
|||||||
foreach (var dev in draft.Devices)
|
foreach (var dev in draft.Devices)
|
||||||
{
|
{
|
||||||
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
||||||
if (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) continue;
|
if (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) continue;
|
||||||
errors.Add(new("SqlConnectionStringPersisted",
|
errors.Add(new("SqlConnectionStringPersisted",
|
||||||
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
||||||
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
||||||
@@ -107,34 +112,6 @@ public static class DraftValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
|
|
||||||
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
|
|
||||||
/// and shaping the config JSON is another rule's job.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="json">The config blob to inspect.</param>
|
|
||||||
/// <param name="key">The property name to look for.</param>
|
|
||||||
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
|
|
||||||
private static bool HasTopLevelKey(string? json, string key)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
|
||||||
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
|
|
||||||
foreach (var property in doc.RootElement.EnumerateObject())
|
|
||||||
{
|
|
||||||
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
catch (System.Text.Json.JsonException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
/// <summary>WP7 Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one place that decides whether a persisted config blob carries a literal Sql
|
||||||
|
/// <c>connectionString</c> (Gitea #498 / #499).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A <c>Sql</c> driver names its credentials indirectly — <c>connectionStringRef</c> resolves from
|
||||||
|
/// the environment / secret store at Initialize — so nothing persisted should ever contain a
|
||||||
|
/// database password. The typed <c>SqlDriverConfigDto</c> already drops a <c>connectionString</c>
|
||||||
|
/// key on <b>read</b>, but discarding a secret on read is not the same as refusing to store it:
|
||||||
|
/// config blobs are schemaless JSON columns authored through raw-JSON textareas, so the key can
|
||||||
|
/// be written even though the runtime ignores it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// There are <b>three</b> surfaces a Sql driver's config is persisted on, and they need different
|
||||||
|
/// enforcement points:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>DriverInstance.DriverConfig</c> and <c>Device.DeviceConfig</c> — both in the
|
||||||
|
/// deployed artifact, both visible to <c>DraftSnapshot</c>, both gated by
|
||||||
|
/// <c>DraftValidator</c>.</item>
|
||||||
|
/// <item><c>ClusterNode.DriverConfigOverridesJson</c> — the per-node override map. It is
|
||||||
|
/// <b>not</b> in <c>DraftSnapshot</c>, and deliberately does not go there: adding a
|
||||||
|
/// <c>ClusterNode</c> collection would widen the snapshot (and every builder of one) for a
|
||||||
|
/// single rule about a value that is not part of the artifact at all. More to the point, a
|
||||||
|
/// deploy gate is the wrong instrument here — the artifact never carries node overrides, so
|
||||||
|
/// blocking a deploy would not stop the credential being stored. It is already in the
|
||||||
|
/// database by then. This surface is therefore gated at the <b>save</b>, which is the only
|
||||||
|
/// point where "refuse to store it" is literally true.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Deliberately narrow.</b> This checks the <c>Sql</c> driver's <c>connectionString</c> key
|
||||||
|
/// only — not a general "credential-shaped key" sweep over every driver type. A broader rule
|
||||||
|
/// would start refusing configs that are legitimate today for drivers that never made this
|
||||||
|
/// guarantee, turning defence-in-depth into a regression. Widen it per driver, as each driver
|
||||||
|
/// gains an indirect-credential contract of its own.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class SqlCredentialGuard
|
||||||
|
{
|
||||||
|
/// <summary>The config key a Sql driver must never persist. Matched case-insensitively.</summary>
|
||||||
|
public const string ForbiddenKey = "connectionString";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether <paramref name="configJson"/> is a JSON object carrying <see cref="ForbiddenKey"/> at
|
||||||
|
/// its top level.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds <c>ConnectionString</c> to a
|
||||||
|
/// <c>connectionString</c> property by default, so a case variant is the same key, not a
|
||||||
|
/// different one. Only the top level is scanned — the DTO is flat, so a nested occurrence cannot
|
||||||
|
/// bind and is not the credential-shaped mistake this exists to catch.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="configJson">The config blob to inspect.</param>
|
||||||
|
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
|
||||||
|
public static bool CarriesLiteralConnectionString(string? configJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(configJson)) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(configJson);
|
||||||
|
return doc.RootElement.ValueKind == JsonValueKind.Object
|
||||||
|
&& HasTopLevelKey(doc.RootElement, ForbiddenKey);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// A malformed blob simply has no keys. Shaping the config JSON is another rule's job, and
|
||||||
|
// throwing here would turn a formatting mistake into a credential-guard failure.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>DriverInstanceId</c>s in a <c>ClusterNode.DriverConfigOverridesJson</c> map whose
|
||||||
|
/// override object carries a literal connection string, restricted to instances that are actually
|
||||||
|
/// Sql drivers.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The override map is shaped <c>{ "<DriverInstanceId>": { …driver config keys… } }</c> and is
|
||||||
|
/// merged onto the cluster-level <c>DriverConfig</c>, so a credential pasted into one lands in
|
||||||
|
/// exactly the place <see cref="CarriesLiteralConnectionString"/> already refuses on the driver
|
||||||
|
/// itself.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="overridesJson">The node's override map. Null / blank / malformed yields no
|
||||||
|
/// violations.</param>
|
||||||
|
/// <param name="sqlDriverInstanceIds">The ids of driver instances whose <c>DriverType</c> is
|
||||||
|
/// <c>Sql</c>. Keys outside this set are ignored — a non-Sql driver never made the indirect-credential
|
||||||
|
/// guarantee, so flagging it would be a regression, not defence in depth.</param>
|
||||||
|
/// <returns>The offending driver-instance ids, in document order; empty when there are none.</returns>
|
||||||
|
public static IReadOnlyList<string> FindNodeOverrideViolations(
|
||||||
|
string? overridesJson, IReadOnlyCollection<string> sqlDriverInstanceIds)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(overridesJson) || sqlDriverInstanceIds.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqlIds = sqlDriverInstanceIds as IReadOnlySet<string>
|
||||||
|
?? sqlDriverInstanceIds.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(overridesJson);
|
||||||
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return [];
|
||||||
|
|
||||||
|
List<string>? offenders = null;
|
||||||
|
foreach (var entry in doc.RootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!sqlIds.Contains(entry.Name)) continue;
|
||||||
|
if (entry.Value.ValueKind != JsonValueKind.Object) continue;
|
||||||
|
if (!HasTopLevelKey(entry.Value, ForbiddenKey)) continue;
|
||||||
|
|
||||||
|
(offenders ??= []).Add(entry.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (IReadOnlyList<string>?)offenders ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether <paramref name="obj"/> carries <paramref name="key"/> at its top level,
|
||||||
|
/// case-insensitively.</summary>
|
||||||
|
private static bool HasTopLevelKey(JsonElement obj, string key)
|
||||||
|
{
|
||||||
|
foreach (var property in obj.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
@using System.ComponentModel.DataAnnotations
|
@using System.ComponentModel.DataAnnotations
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration
|
@using ZB.MOM.WW.OtOpcUa.Configuration
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Validation
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||||
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
@inject IDbContextFactory<OtOpcUaConfigDbContext> DbFactory
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject AuthenticationStateProvider AuthState
|
@inject AuthenticationStateProvider AuthState
|
||||||
@@ -178,6 +180,33 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
await using var db = await DbFactory.CreateDbContextAsync();
|
await using var db = await DbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
// #499 — the third Sql-credential surface. DriverConfigOverridesJson is merged onto the
|
||||||
|
// cluster-level DriverConfig, so a literal connectionString pasted here leaks exactly as one
|
||||||
|
// pasted into the driver itself. The deploy gate cannot see it (DraftSnapshot carries no
|
||||||
|
// ClusterNode rows) and would be the wrong place anyway: node overrides never enter the
|
||||||
|
// artifact, so by the time a deploy ran the credential would already be stored. Refuse the
|
||||||
|
// save instead. The message names the driver instance but NEVER the value.
|
||||||
|
if (!string.IsNullOrWhiteSpace(_form.DriverConfigOverridesJson))
|
||||||
|
{
|
||||||
|
var sqlDriverIds = await db.DriverInstances
|
||||||
|
.Where(d => d.ClusterId == ClusterId && d.DriverType == DriverTypeNames.Sql)
|
||||||
|
.Select(d => d.DriverInstanceId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var offenders = SqlCredentialGuard.FindNodeOverrideViolations(
|
||||||
|
_form.DriverConfigOverridesJson, sqlDriverIds);
|
||||||
|
if (offenders.Count > 0)
|
||||||
|
{
|
||||||
|
_error =
|
||||||
|
$"Driver config overrides carry a '{SqlCredentialGuard.ForbiddenKey}' for Sql driver " +
|
||||||
|
$"instance(s) {string.Join(", ", offenders)}. A Sql driver must name its credentials " +
|
||||||
|
"indirectly via 'connectionStringRef', which resolves from the environment / secret " +
|
||||||
|
"store at Initialize; a literal connection string here would be stored in the config " +
|
||||||
|
"database, and the runtime ignores it anyway. Remove the key.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (IsNew)
|
if (IsNew)
|
||||||
{
|
{
|
||||||
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
|
if (await db.ClusterNodes.AnyAsync(n => n.NodeId == _form.NodeId))
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="SqlCredentialGuard"/> — the shared "a Sql driver never persists a literal connection
|
||||||
|
/// string" check behind both the #498 deploy gate and the #499 node-override save gate.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The node-override half is the one #499 is about: <c>ClusterNode.DriverConfigOverridesJson</c> is a
|
||||||
|
/// map keyed by <c>DriverInstanceId</c> that is merged onto the cluster-level <c>DriverConfig</c>, and
|
||||||
|
/// it is invisible to <c>DraftSnapshot</c> — so nothing gated it at all before.
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class SqlCredentialGuardTests
|
||||||
|
{
|
||||||
|
/// <summary>The literal an operator would paste into a raw-JSON textarea.</summary>
|
||||||
|
private const string Leaked =
|
||||||
|
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
|
||||||
|
|
||||||
|
private const string Clean = """{"provider":"SqlServer","connectionStringRef":"Sql:Line3"}""";
|
||||||
|
|
||||||
|
// ---- CarriesLiteralConnectionString --------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>The key is caught at the top level of a config blob.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_literal_connectionString_is_detected()
|
||||||
|
=> SqlCredentialGuard.CarriesLiteralConnectionString(Leaked).ShouldBeTrue();
|
||||||
|
|
||||||
|
/// <summary>The supported indirect form is not a violation — otherwise the rule would block the fix
|
||||||
|
/// it tells operators to apply.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void The_indirect_connectionStringRef_form_is_clean()
|
||||||
|
=> SqlCredentialGuard.CarriesLiteralConnectionString(Clean).ShouldBeFalse();
|
||||||
|
|
||||||
|
/// <summary>System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by
|
||||||
|
/// default, so a case variant is the same key — not a bypass.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("""{"ConnectionString":"Server=x;Password=p"}""")]
|
||||||
|
[InlineData("""{"CONNECTIONSTRING":"Server=x;Password=p"}""")]
|
||||||
|
[InlineData("""{"connectionstring":"Server=x;Password=p"}""")]
|
||||||
|
public void Case_variants_are_the_same_key(string json)
|
||||||
|
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeTrue();
|
||||||
|
|
||||||
|
/// <summary>Blank, malformed and non-object blobs simply have no keys. Shaping the config JSON is
|
||||||
|
/// another rule's job, and throwing here would turn a formatting mistake into a credential failure.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("{ not json")]
|
||||||
|
[InlineData("[1,2,3]")]
|
||||||
|
[InlineData("\"a string\"")]
|
||||||
|
public void Blank_malformed_and_non_object_blobs_are_not_violations(string? json)
|
||||||
|
=> SqlCredentialGuard.CarriesLiteralConnectionString(json).ShouldBeFalse();
|
||||||
|
|
||||||
|
/// <summary>Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind and is
|
||||||
|
/// not the credential-shaped mistake this rule exists to catch.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_nested_occurrence_is_not_flagged()
|
||||||
|
=> SqlCredentialGuard
|
||||||
|
.CarriesLiteralConnectionString("""{"nested":{"connectionString":"Server=x"}}""")
|
||||||
|
.ShouldBeFalse();
|
||||||
|
|
||||||
|
// ---- FindNodeOverrideViolations (#499) -----------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>The #499 leak: a credential pasted into a node's per-driver override map.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_credential_in_a_node_override_for_a_Sql_driver_is_a_violation()
|
||||||
|
{
|
||||||
|
var overrides = $$"""{"di-sql": {{Leaked}} }""";
|
||||||
|
|
||||||
|
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"])
|
||||||
|
.ShouldBe(["di-sql"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Every offending instance is reported, not just the first — an operator fixing one at a
|
||||||
|
/// time would otherwise need as many save attempts as there are leaks.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Every_offending_instance_is_reported()
|
||||||
|
{
|
||||||
|
var overrides = $$"""{"di-a": {{Leaked}}, "di-clean": {{Clean}}, "di-b": {{Leaked}} }""";
|
||||||
|
|
||||||
|
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-a", "di-b", "di-clean"])
|
||||||
|
.ShouldBe(["di-a", "di-b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Keys outside the Sql set are ignored. A non-Sql driver never made the
|
||||||
|
/// indirect-credential guarantee, so flagging it would break a config that is legitimate today —
|
||||||
|
/// a regression, not defence in depth.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void An_override_for_a_non_Sql_driver_is_ignored()
|
||||||
|
{
|
||||||
|
var overrides = $$"""{"di-modbus": {{Leaked}} }""";
|
||||||
|
|
||||||
|
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The realistic override — a node-specific endpoint — must keep saving.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_normal_override_is_clean()
|
||||||
|
{
|
||||||
|
var overrides = """{"di-sql": {"commandTimeoutSeconds": 45}}""";
|
||||||
|
|
||||||
|
SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Nothing to check when the cluster has no Sql drivers, or the node has no overrides.</summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
[InlineData("{ not json")]
|
||||||
|
[InlineData("[1,2,3]")]
|
||||||
|
public void Blank_and_malformed_override_maps_yield_no_violations(string? overrides)
|
||||||
|
=> SqlCredentialGuard.FindNodeOverrideViolations(overrides, ["di-sql"]).ShouldBeEmpty();
|
||||||
|
|
||||||
|
/// <summary>An empty Sql-driver set short-circuits — there is no instance the key could belong to.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void No_Sql_drivers_means_no_violations()
|
||||||
|
=> SqlCredentialGuard.FindNodeOverrideViolations($$"""{"di-sql": {{Leaked}} }""", [])
|
||||||
|
.ShouldBeEmpty();
|
||||||
|
|
||||||
|
/// <summary>A non-object override entry cannot carry config keys, and must not throw.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void A_non_object_override_entry_is_skipped()
|
||||||
|
=> SqlCredentialGuard.FindNodeOverrideViolations("""{"di-sql": "connectionString"}""", ["di-sql"])
|
||||||
|
.ShouldBeEmpty();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user