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); } }