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:
@@ -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