using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Audit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
///
/// lmxopcua#835 security review: I-1 (an UNSAVED endpoint may not borrow the named owner's own secrets; every
/// credential-carrying Test Connect / browse is audited) and L-2 (a ConfigDb fault in the browse check is a refusal,
/// not an exception).
///
[Trait("Category", "Unit")]
public sealed class CredentialUseReviewTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private const string StoredEndpoint = "http://gw-victim:5001";
private static string Galaxy(string endpoint, string reference) =>
"""{"Gateway":{"Endpoint":"EP","ApiKeySecretRef":"REF"}}""".Replace("EP", endpoint).Replace("REF", reference);
/// The victim's driver stores no endpoint; its device stores (v3 layout).
private static IDbContextFactory VictimFactory()
{
var name = $"i1-{Guid.NewGuid():N}";
using (var db = RawTreeTestDb.CreateNamed(name))
{
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = "DRV-victim", ClusterId = "c", Name = "Victim", DriverType = DriverTypeNames.Galaxy,
DriverConfig = """{"Gateway":{"ApiKeySecretRef":"secret:galaxy/DRV-victim/apikey"}}""",
});
db.Devices.Add(new Device
{
DeviceId = "DEV-victim", DriverInstanceId = "DRV-victim", Name = "gw",
DeviceConfig = Galaxy(StoredEndpoint, "secret:galaxy/DRV-victim/apikey"),
});
db.SaveChanges();
}
return RawTreeTestDb.Factory(name);
}
private static BrowserSessionService Service(
RecordingBrowser browser, IDbContextFactory? factory, IAuditWriter? audit = null) =>
new([browser], new BrowseSessionRegistry(), NullLogger.Instance, new NoUniversal(), null!,
new FixedUser("designer1"), factory, audit);
[Fact]
public async Task Browse_from_the_owners_device_with_an_unsaved_endpoint_cannot_use_the_owners_secret()
{
var browser = new RecordingBrowser();
var result = await Service(browser, VictimFactory()).OpenAsync(DriverTypeNames.Galaxy,
Galaxy("http://attacker:5001", "secret:galaxy/DRV-victim/apikey"), "DRV-victim", Ct);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("lmxopcua#835");
result.Message.ShouldContain("save the change first");
browser.Opens.ShouldBe(0);
}
[Fact]
public async Task Browse_with_the_saved_endpoint_may_use_the_owners_secret()
{
var browser = new RecordingBrowser();
await Service(browser, VictimFactory()).OpenAsync(DriverTypeNames.Galaxy,
Galaxy(StoredEndpoint, "secret:galaxy/DRV-victim/apikey"), "DRV-victim", Ct);
browser.Opens.ShouldBe(1);
}
[Fact]
public async Task Browse_naming_an_owner_of_another_type_gets_no_owner()
{
var browser = new RecordingBrowser();
var result = await Service(browser, VictimFactory()).OpenAsync(DriverTypeNames.Galaxy,
Galaxy(StoredEndpoint, "secret:galaxy/DRV-victim/apikey"), "DRV-does-not-exist", Ct);
result.Ok.ShouldBeFalse();
browser.Opens.ShouldBe(0);
}
[Fact]
public async Task A_config_db_fault_in_the_browse_check_is_a_refusal_not_an_exception()
{
var browser = new RecordingBrowser();
var result = await Service(browser, new ThrowingFactory()).OpenAsync(DriverTypeNames.Galaxy,
Galaxy(StoredEndpoint, "secret:galaxy/shared/apikey"), "DRV-victim", Ct);
result.Ok.ShouldBeFalse();
result.Message.ShouldNotBeNull().ShouldContain("could not be read");
browser.Opens.ShouldBe(0);
}
[Fact]
public async Task Every_credential_carrying_browse_is_audited_with_user_driver_host_and_outcome()
{
var audit = new RecordingAudit();
var browser = new RecordingBrowser();
var svc = Service(browser, VictimFactory(), audit);
await svc.OpenAsync(DriverTypeNames.Galaxy, Galaxy("http://attacker:5001", "secret:galaxy/DRV-victim/apikey"), "DRV-victim", Ct);
await svc.OpenAsync(DriverTypeNames.Galaxy, Galaxy(StoredEndpoint, "env:OTOPCUA_GALAXY_KEY"), "DRV-victim", Ct);
await svc.OpenAsync(DriverTypeNames.Galaxy, Galaxy(StoredEndpoint, "dev:literal"), "DRV-victim", Ct); // no reference ⇒ no row
audit.Events.Count.ShouldBe(2);
var refused = audit.Events[0];
refused.Category.ShouldBe(CredentialUseAudit.Category);
refused.Action.ShouldBe(CredentialUseAudit.BrowseAction);
refused.Actor.ShouldBe("designer1");
refused.Target.ShouldBe("DRV-victim");
refused.Outcome.ShouldBe(AuditOutcome.Failure);
refused.DetailsJson.ShouldNotBeNull().ShouldContain("attacker:5001");
refused.DetailsJson.ShouldNotContain("DRV-victim/apikey"); // the reference itself is never recorded
audit.Events[1].DetailsJson.ShouldNotBeNull().ShouldContain("gw-victim:5001");
}
[Fact]
public async Task Every_credential_carrying_test_connect_is_audited()
{
var audit = new RecordingAudit();
var probe = new AdminProbeService(
new FixedReplyClient(new TestDriverConnectResult(false, "Test Connect refused: x", null, Guid.Empty)),
audit, new FixedUser("designer2"), NullLogger.Instance);
await probe.TestAsync(DriverTypeNames.Galaxy, Galaxy("http://attacker:5001", "secret:galaxy/DRV-victim/apikey"), 5, Ct, "DRV-victim");
await probe.TestAsync(DriverTypeNames.Modbus, """{"Host":"plc"}""", 5, Ct);
var e = audit.Events.ShouldHaveSingleItem();
e.Action.ShouldBe(CredentialUseAudit.TestConnectAction);
e.Actor.ShouldBe("designer2");
e.Target.ShouldBe("DRV-victim");
e.Outcome.ShouldBe(AuditOutcome.Failure);
e.DetailsJson.ShouldNotBeNull().ShouldContain("attacker:5001");
}
[Fact]
public void Hosts_drop_uri_user_info()
{
CredentialUseAudit.Hosts(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://user:pw@plc1:4840/x"}""")
.ShouldHaveSingleItem().ShouldBe("plc1:4840");
}
private sealed class RecordingBrowser : IDriverBrowser
{
public int Opens { get; private set; }
public string DriverType => DriverTypeNames.Galaxy;
public Task OpenAsync(string configJson, CancellationToken cancellationToken)
{
Opens++;
throw new InvalidOperationException("opened");
}
}
private sealed class NoUniversal : IUniversalDriverBrowser
{
public bool CanBrowse(string driverType, string configJson) => false;
public Task OpenAsync(string driverType, string configJson, CancellationToken ct) =>
throw new InvalidOperationException("opened");
}
private sealed class FixedUser(string name) : AuthenticationStateProvider
{
public override Task GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, name)], "test"))));
}
private sealed class RecordingAudit : IAuditWriter
{
public List Events { get; } = [];
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
{
Events.Add(evt);
return Task.CompletedTask;
}
}
private sealed class ThrowingFactory : IDbContextFactory
{
public OtOpcUaConfigDbContext CreateDbContext() => throw new InvalidOperationException("ConfigDb unreachable");
}
private sealed class FixedReplyClient(TestDriverConnectResult reply) : IAdminOperationsClient
{
public Task StartDeploymentAsync(string createdBy, CancellationToken ct) => throw new NotSupportedException();
public Task AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct) =>
throw new NotSupportedException();
public Task ShelveAlarmAsync(
string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc, string? comment, CancellationToken ct) =>
throw new NotSupportedException();
public Task AskAsync(object message, CancellationToken ct) => Task.FromResult((T)(object)reply);
}
}