Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorSqlSecretTests.cs
T
Joseph Doherty 1919a8e538 feat(config): reject a persisted Sql connectionString at the deploy gate (#498)
The Sql driver's "a pasted literal connection string is never persisted" guarantee was
enforced only on the READ path: SqlDriverConfigDto has no connectionString property and
UnmappedMemberHandling.Skip drops the key on deserialization. Nothing stopped it being
WRITTEN. Config blobs are schemaless JSON columns and the fallback authoring pattern for
a driver without a typed form is a raw-JSON textarea, so an operator pasting
{"connectionString":"Server=...;Password=..."} would put a live credential in the
ConfigDb — persisted, replicated to every node's artifact cache, readable by 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.

DraftValidator.ValidateSqlConnectionStringNotPersisted fails the deploy
(SqlConnectionStringPersisted) when a Sql-typed driver instance's DriverConfig, or the
DeviceConfig of any device beneath it, carries a top-level connectionString key.

- Both surfaces are checked because DeviceConfig is merged onto DriverConfig before the
  DTO sees it — a credential pasted into the device blob is the identical leak.
- The key is matched case-insensitively: System.Text.Json binds ConnectionString to a
  connectionString property by default, so an ordinal match would leave the obvious
  bypass open.
- Only the top level is scanned; the DTO is flat, so a nested occurrence cannot bind and
  is not the mistake this rule exists to catch.
- The message never echoes the value — validation errors reach the AdminUI, the deploy
  log and the audit trail, and repeating the string there would leak the credential the
  rule is refusing to store. Pinned by a test.
- Malformed/blank/non-object JSON never throws: a parse failure would take down every
  other rule in the same pass.

16 tests. Verified falsifiable — with the rule disabled the 6 positive assertions fail
and the 10 negatives stay green, so none of them passes vacuously.

Design §8.2's "if an inline connectionString is ever permitted (dev convenience only),
the AdminUI redacts it" carve-out is withdrawn in the same change: redaction only hides a
credential that has already been written.

Not covered, and filed as a follow-up: ClusterNode.DriverConfigOverridesJson is a third
persistence surface for a driver config blob, but it is not part of DraftSnapshot, so
this validator cannot see it.
2026-07-25 15:37:48 -04:00

174 lines
6.8 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// The Gitea #498 deploy gate: a <c>Sql</c> driver's persisted config may never carry a literal
/// <c>connectionString</c>. The typed DTO already drops the key on <em>read</em>; these pin the
/// <em>write</em> side, which is where a credential would actually land in the config database.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorSqlSecretTests
{
private const string Code = "SqlConnectionStringPersisted";
/// <summary>A realistic leak: the literal an operator would paste into a raw-JSON config textarea.</summary>
private const string LeakedConfig =
"""{"provider":"SqlServer","connectionString":"Server=sql,1433;Database=Mes;User ID=sa;Password=hunter2"}""";
private static DriverInstance SqlDriver(string config) => new()
{
DriverInstanceId = "di-sql", ClusterId = "c", Name = "line3-sql",
DriverType = "Sql", DriverConfig = config,
};
private static Device Device(string driverInstanceId, string config) => new()
{
DeviceId = "dev-1", DriverInstanceId = driverInstanceId, Name = "Device1", DeviceConfig = config,
};
private static DraftSnapshot Draft(DriverInstance driver, Device? device = null) => new()
{
GenerationId = 1,
ClusterId = "c",
DriverInstances = [driver],
Devices = device is null ? [] : [device],
};
[Fact]
public void Literal_connectionString_in_DriverConfig_is_a_deploy_error()
{
var errors = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig)));
errors.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// The error text reaches the AdminUI, the deploy log and the audit trail, so it must describe the
/// problem without repeating the credential it is refusing to store.
/// </summary>
[Fact]
public void Error_message_does_not_echo_the_credential()
{
var error = DraftValidator.Validate(Draft(SqlDriver(LeakedConfig))).First(e => e.Code == Code);
error.Message.ShouldNotContain("hunter2");
error.Message.ShouldNotContain("Server=sql,1433");
error.Message.ShouldContain("connectionStringRef");
}
/// <summary>
/// System.Text.Json binds <c>ConnectionString</c> to a <c>connectionString</c> property by default, so
/// a case variant is the same key — matching it ordinally would leave the obvious bypass wide open.
/// </summary>
[Theory]
[InlineData("ConnectionString")]
[InlineData("CONNECTIONSTRING")]
[InlineData("connectionstring")]
public void Key_match_is_case_insensitive(string key)
{
var config = $$"""{"provider":"SqlServer","{{key}}":"Server=s;Password=p"}""";
DraftValidator.Validate(Draft(SqlDriver(config)))
.ShouldContain(e => e.Code == Code && e.Context == "di-sql");
}
/// <summary>
/// DeviceConfig is merged onto DriverConfig before the driver's DTO sees it, so a credential pasted
/// there is the identical leak and must fail the same way.
/// </summary>
[Fact]
public void Literal_connectionString_in_a_Sql_devices_DeviceConfig_is_a_deploy_error()
{
var draft = Draft(SqlDriver("""{"connectionStringRef":"DevSql"}"""), Device("di-sql", LeakedConfig));
DraftValidator.Validate(draft).ShouldContain(e => e.Code == Code && e.Context == "dev-1");
}
[Fact]
public void A_properly_authored_connectionStringRef_passes()
{
var draft = Draft(
SqlDriver("""{"provider":"SqlServer","connectionStringRef":"DevSql","nullIsBad":true}"""),
Device("di-sql", """{"pollIntervalMs":1000}"""));
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// The rule is scoped to the Sql driver type. A non-Sql driver is not in its remit — widening the gate
/// to every driver is a separate decision, and silently failing an unrelated driver's deploy here would
/// be a regression, not defence in depth.
/// </summary>
[Fact]
public void A_non_Sql_driver_carrying_the_key_is_not_flagged_by_this_rule()
{
var modbus = new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = LeakedConfig,
};
DraftValidator.Validate(Draft(modbus)).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// A device under a <em>different</em> driver must not be attributed to the Sql instance — the device
/// scan keys off DriverInstanceId, and getting that wrong would flag innocent devices.
/// </summary>
[Fact]
public void A_device_under_a_non_Sql_driver_is_not_flagged()
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
SqlDriver("""{"connectionStringRef":"DevSql"}"""),
new DriverInstance
{
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
DriverType = "Modbus", DriverConfig = "{}",
},
],
Devices = [Device("di-mb", LeakedConfig)],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Malformed or non-object config must not throw out of the validator: shaping the JSON is another
/// rule's job, and a parse failure here would take down every other check in the same pass.
/// </summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
[InlineData("[1,2,3]")]
[InlineData("\"connectionString\"")]
[InlineData("{\"provider\":\"SqlServer\"")]
public void Malformed_config_neither_throws_nor_flags(string config)
{
Should.NotThrow(() => DraftValidator.Validate(Draft(SqlDriver(config))))
.ShouldNotContain(e => e.Code == Code);
}
/// <summary>
/// Only the top level is scanned. The DTO is flat, so a nested occurrence cannot bind to anything and
/// is not the credential-shaped mistake this rule exists to catch; flagging it would be a false
/// positive on, say, a tag blob that happens to describe a connection string.
/// </summary>
[Fact]
public void A_nested_connectionString_is_not_flagged()
{
var config = """{"connectionStringRef":"DevSql","notes":{"connectionString":"documented elsewhere"}}""";
DraftValidator.Validate(Draft(SqlDriver(config))).ShouldNotContain(e => e.Code == Code);
}
}