Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/CredentialInstanceScopeTests.cs
T
Joseph Doherty d68dff0946 fix(security): id-form credential references match by id only; id-shaped driver names refused (#849 review)
A driver named 'drv' (env form DRV) matched every sibling's id-form variable
env:<PREFIX>DRV_<12HEX>_... as 'another instance', breaking the recommended id
convention fleet-wide - including for a name that pre-dates the rule.
ReferenceNames is split into ReferenceNamesId / ReferenceNamesName; a reference
whose instance part is id-shaped (secret: DRV-<12 hex>, env: DRV_<12 HEX>[_...])
is matched by id only. DescribeNameChangeProblemsAsync also refuses new names
whose env form is DRV or starts with an id form (IsReservedInstanceName) at
create, /raw rename and config-modal update.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 06:35:52 -05:00

248 lines
14 KiB
C#

using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// lmxopcua#835: inside one namespace, a credential reference whose instance segment
/// (<c>secret:galaxy/&lt;X&gt;/…</c>) names ANOTHER existing instance of the same driver type is refused.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialInstanceScopeTests
{
private static readonly DriverInstanceIdentity[] Instances =
[
new("DRV-aaa", "PlantA", DriverTypeNames.Galaxy),
new("DRV-bbb", "PlantB", DriverTypeNames.Galaxy),
new("DRV-ccc", "UpstreamC", DriverTypeNames.OpcUaClient),
new("DRV-ddd", "UpstreamD", DriverTypeNames.OpcUaClient),
];
private static string Galaxy(string apiKeyRef) =>
"{\"Gateway\":{\"Endpoint\":\"http://evil:5001\",\"ApiKeySecretRef\":" + System.Text.Json.JsonSerializer.Serialize(apiKeyRef) + "}}";
[Theory]
[InlineData("secret:galaxy/DRV-bbb/apikey")]
[InlineData("secret:galaxy/PlantB/apikey")]
[InlineData("secret:galaxy/plantb/apikey")] // case-insensitive
[InlineData("secret:otopcua/galaxy/PlantB/apikey")] // the second namespace
[InlineData("${secret:galaxy/PlantB/apikey}")] // the token spelling
[InlineData("secret:galaxy/PlantB")] // the segment alone
public void A_reference_naming_another_instance_is_refused(string reference)
{
var problem = DriverCredentialPolicy
.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), "DRV-aaa", Instances)
.ShouldHaveSingleItem();
problem.ShouldContain("Gateway.ApiKeySecretRef");
problem.ShouldContain("lmxopcua#835");
problem.ShouldContain("DRV-bbb");
}
[Theory]
[InlineData("secret:galaxy/DRV-aaa/apikey")] // its own id
[InlineData("secret:galaxy/PlantA/apikey")] // its own name
[InlineData("secret:galaxy/gw/apikey")] // a shared name no instance uses
[InlineData("secret:galaxy/apikey")]
[InlineData("secret:galaxy/UpstreamC/apikey")] // another TYPE's instance — not this namespace's owner
[InlineData("env:OTOPCUA_GALAXY_PLANTA_KEY")] // its own name in env form (lmxopcua#849)
[InlineData("env:OTOPCUA_GALAXY_DRV_AAA_KEY")] // its own id in env form
[InlineData("env:GALAXY_MXGW_API_KEY")] // a shared variable that names no instance
[InlineData("env:OTOPCUA_GALAXY_PLANTBX_KEY")] // 'PLANTB' only at a '_' boundary
[InlineData("dev:k")]
[InlineData("")]
public void Own_shared_and_non_secret_references_pass(string reference) =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), "DRV-aaa", Instances)
.ShouldBeEmpty();
[Fact]
public void A_driver_being_created_may_name_no_existing_instance() =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/PlantA/apikey"), null, Instances)
.ShouldHaveSingleItem();
[Fact]
public void Renaming_ones_own_driver_after_the_victim_does_not_unlock_the_victims_secret()
{
// Attacker DRV-zzz renamed itself "PlantB" (names are unique per folder, not fleet-wide).
var withTwin = Instances.Append(new DriverInstanceIdentity("DRV-zzz", "PlantB", DriverTypeNames.Galaxy));
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/PlantB/apikey"), "DRV-zzz", withTwin)
.ShouldHaveSingleItem();
}
[Fact]
public void The_ack_password_and_device_level_fields_are_checked_too()
{
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Galaxy, """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/DRV-bbb/ack"}}""", "DRV-aaa", Instances)
.ShouldHaveSingleItem().ShouldContain("AlarmAck.FallbackPasswordSecretRef");
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Galaxy, """{"Devices":[{"Gateway":{"ApiKeySecretRef":"secret:galaxy/PlantB/k"}}]}""", "DRV-aaa", Instances)
.ShouldHaveSingleItem().ShouldContain("Devices[0].Gateway.ApiKeySecretRef");
}
[Theory]
[InlineData("""{"Password":"secret:opcuaclient/UpstreamD/pw"}""", 1)]
[InlineData("""{"UserCertificatePassword":"secret:opcuaclient/DRV-ddd/pfx"}""", 1)]
[InlineData("""{"Password":"secret:opcuaclient/UpstreamC/pw"}""", 0)]
[InlineData("""{"Password":"secret:opcuaclient/shared/pw"}""", 0)]
public void OpcUaClient_has_the_same_rule(string config, int expected) =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.OpcUaClient, config, "DRV-ccc", Instances)
.Count.ShouldBe(expected);
[Fact]
public void Heidenhain_has_the_same_rule()
{
IEnumerable<DriverInstanceIdentity> hh = [new("H1", "line1", DriverTypeNames.Heidenhain), new("H2", "line2", DriverTypeNames.Heidenhain)];
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Heidenhain, """{"Access":{"InspectPassword":"secret:heidenhain/line2/inspect"}}""", "H1", hh)
.ShouldHaveSingleItem();
}
[Fact]
public void HasInstanceSegments_gates_the_ConfigDb_read()
{
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("secret:galaxy/x/apikey")).ShouldBeTrue();
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_X")).ShouldBeTrue(); // #849
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, Galaxy("env:ConnectionStrings__ConfigDb")).ShouldBeFalse();
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Modbus, """{"Password":"secret:galaxy/x"}""").ShouldBeFalse();
DriverCredentialPolicy.HasInstanceSegments(DriverTypeNames.Galaxy, "not json").ShouldBeFalse();
}
[Fact]
public void The_combined_overload_reports_both_rules()
{
var config = """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/PlantB/k"},"AlarmAck":{"FallbackPasswordSecretRef":"env:ConnectionStrings__ConfigDb"}}""";
var problems = DriverCredentialPolicy.DescribeProblems(DriverTypeNames.Galaxy, config, "DRV-aaa", Instances);
problems.Count.ShouldBe(2);
problems.ShouldContain(p => p.Contains("lmxopcua#815"));
problems.ShouldContain(p => p.Contains("lmxopcua#835"));
}
// ---------------------------------------------------------------------------------------------------------------
// lmxopcua#849 — env: references follow the same per-instance rule (env:<PREFIX><INSTANCE>_…)
// ---------------------------------------------------------------------------------------------------------------
[Theory]
[InlineData("env:OTOPCUA_GALAXY_PLANTB_KEY")] // another instance's name, upper-cased
[InlineData("env:OTOPCUA_GALAXY_PLANTB")] // the part alone
[InlineData("env:OTOPCUA_GALAXY_DRV_BBB_KEY")] // another instance's id, '-' as '_'
[InlineData("env:GALAXY_MXGW_PLANTB_API_KEY")] // the legacy prefix too
public void An_env_reference_naming_another_instance_is_refused(string reference)
{
var problem = DriverCredentialPolicy
.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), "DRV-aaa", Instances)
.ShouldHaveSingleItem();
problem.ShouldContain("lmxopcua#849");
problem.ShouldContain("DRV-bbb");
problem.ShouldContain("Gateway.ApiKeySecretRef");
}
[Fact]
public void An_env_match_against_another_instance_wins_over_the_owners()
{
// Owner "Line 1" (LINE_1) and another instance "Line" (LINE): OTOPCUA_GALAXY_LINE_1_KEY names both.
IEnumerable<DriverInstanceIdentity> lines =
[new("DRV-1", "Line 1", DriverTypeNames.Galaxy), new("DRV-2", "Line", DriverTypeNames.Galaxy)];
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_LINE_1_KEY"), "DRV-1", lines)
.ShouldHaveSingleItem();
// …while the owner's id-scoped variable names only the owner.
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_1_KEY"), "DRV-1", lines)
.ShouldBeEmpty();
}
[Fact]
public void OpcUaClient_and_Heidenhain_env_forms_have_the_rule()
{
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.OpcUaClient, """{"Password":"env:OTOPCUA_OPCUACLIENT_UPSTREAMD_PW"}""", "DRV-ccc", Instances)
.ShouldHaveSingleItem().ShouldContain("lmxopcua#849");
IEnumerable<DriverInstanceIdentity> hh = [new("H1", "line1", DriverTypeNames.Heidenhain), new("H2", "line2", DriverTypeNames.Heidenhain)];
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Heidenhain, """{"Access":{"DncPassword":"env:OTOPCUA_HEIDENHAIN_LINE2_DNC"}}""", "H1", hh)
.ShouldHaveSingleItem();
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Heidenhain, """{"Access":{"DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC_PASSWORD"}}""", "H1", hh)
.ShouldBeEmpty();
}
[Theory]
[InlineData("DRV-1a2b", "DRV_1A2B")]
[InlineData("Line 1", "LINE_1")]
[InlineData("plant.b", "PLANT_B")]
[InlineData("---", "")]
[InlineData("", "")]
public void Identifiers_normalise_to_environment_form(string identifier, string expected) =>
DriverCredentialPolicy.NormalizeEnvironmentIdentifier(identifier).ShouldBe(expected);
[Fact]
public void Instance_references_are_collected_for_both_kinds()
{
var refs = DriverCredentialPolicy.CollectInstanceReferences(DriverTypeNames.Galaxy,
"""{"Gateway":{"ApiKeySecretRef":"secret:galaxy/gw/k"},"AlarmAck":{"FallbackPasswordSecretRef":"env:OTOPCUA_GALAXY_ACK_PW"}}""");
refs.Count.ShouldBe(2);
refs[0].ShouldBe(new InstanceReference("Gateway.ApiKeySecretRef", CredentialReferenceKind.Secret, "gw"));
refs[1].ShouldBe(new InstanceReference("AlarmAck.FallbackPasswordSecretRef", CredentialReferenceKind.Environment, "ACK_PW"));
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ack").ShouldBeTrue();
DriverCredentialPolicy.ReferenceNamesName(refs[1], "ac").ShouldBeFalse();
DriverCredentialPolicy.ReferenceNamesName(refs[0], "GW").ShouldBeTrue();
}
// ---------------------------------------------------------------------------------------------------------------
// #849 review — a driver NAMED 'drv' (or id-shaped) must not shadow the id convention
// ---------------------------------------------------------------------------------------------------------------
private const string RealId = "DRV-0123456789ab"; // the RawTreeService.NewId shape
private static readonly DriverInstanceIdentity[] WithDrvNamed =
[
new(RealId, "PlantA", DriverTypeNames.Galaxy),
new("DRV-fedcba987654", "drv", DriverTypeNames.Galaxy), // a pre-existing driver named 'drv'
new("DRV-111111111111", "DRV-0123456789AB", DriverTypeNames.Galaxy), // …or named like another's id
];
[Theory]
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY")]
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB")]
[InlineData("env:GALAXY_MXGW_DRV_0123456789AB_API_KEY")]
[InlineData("secret:galaxy/DRV-0123456789ab/apikey")]
public void An_id_form_reference_is_matched_by_id_only_so_a_pre_existing_drv_name_does_not_break_it(string reference) =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy(reference), RealId, WithDrvNamed)
.ShouldBeEmpty();
[Fact]
public void An_id_form_reference_still_names_the_other_instance_by_its_id() =>
DriverCredentialPolicy.DescribeOtherInstanceProblems(
DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY"), "DRV-fedcba987654", WithDrvNamed)
.ShouldHaveSingleItem().ShouldContain(RealId);
[Fact]
public void A_drv_name_still_matches_a_reference_that_is_not_id_shaped() =>
// 'DRV_FOO' is not an id form, so the name 'drv' does claim it (the create/rename check refuses that name anyway).
DriverCredentialPolicy.DescribeOtherInstanceProblems(DriverTypeNames.Galaxy, Galaxy("env:OTOPCUA_GALAXY_DRV_FOO_KEY"), RealId, WithDrvNamed)
.ShouldHaveSingleItem();
[Theory]
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB_KEY", true)]
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AB", true)]
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789ABC_KEY", false)] // 13 hex: not an id form
[InlineData("env:OTOPCUA_GALAXY_DRV_0123456789AG_KEY", false)] // not hex
[InlineData("env:OTOPCUA_GALAXY_DRV_KEY", false)]
[InlineData("secret:galaxy/DRV-0123456789ab/k", true)]
[InlineData("secret:galaxy/drv-0123456789AB/k", true)]
[InlineData("secret:galaxy/DRV-0123456789ab-x/k", false)]
public void Id_shape_detection(string reference, bool expected) =>
DriverCredentialPolicy.IsIdShaped(DriverCredentialPolicy.CollectInstanceReferences(DriverTypeNames.Galaxy, Galaxy(reference)).Single())
.ShouldBe(expected);
[Theory]
[InlineData("drv", true)]
[InlineData("DRV", true)]
[InlineData("drv-", false)] // env form 'DRV_' — neither 'DRV' nor an id form
[InlineData("DRV-0123456789ab", true)]
[InlineData("drv 0123456789AB extra", true)]
[InlineData("DRV-0123456789", false)]
[InlineData("Driver", false)]
[InlineData("PlantA", false)]
public void Reserved_instance_names(string name, bool expected) =>
DriverCredentialPolicy.IsReservedInstanceName(name).ShouldBe(expected);
}