using Microsoft.Extensions.Logging.Abstractions; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// WP-6 (Phase 8): Script sandboxing verification. /// Adversarial tests that verify forbidden APIs are blocked at compilation time. /// public class SandboxTests { private readonly ScriptCompilationService _service; public SandboxTests() { _service = new ScriptCompilationService(NullLogger.Instance); } // ── System.IO forbidden ── [Fact] public void Sandbox_FileRead_Blocked() { var result = _service.Compile("evil", """System.IO.File.ReadAllText("/etc/passwd")"""); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Contains("System.IO")); } [Fact] public void Sandbox_FileWrite_Blocked() { var result = _service.Compile("evil", """System.IO.File.WriteAllText("/tmp/hack.txt", "pwned")"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_DirectoryCreate_Blocked() { var result = _service.Compile("evil", """System.IO.Directory.CreateDirectory("/tmp/evil")"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_FileStream_Blocked() { var result = _service.Compile("evil", """new System.IO.FileStream("/tmp/x", System.IO.FileMode.Create)"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_StreamReader_Blocked() { var result = _service.Compile("evil", """new System.IO.StreamReader("/tmp/x")"""); Assert.False(result.IsSuccess); } // ── Process forbidden ── [Fact] public void Sandbox_ProcessStart_Blocked() { var result = _service.Compile("evil", """System.Diagnostics.Process.Start("cmd.exe", "/c dir")"""); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Contains("Process")); } [Fact] public void Sandbox_ProcessStartInfo_Blocked() { var code = """ var psi = new System.Diagnostics.Process(); psi.StartInfo.FileName = "bash"; """; var result = _service.Compile("evil", code); Assert.False(result.IsSuccess); } // ── Threading forbidden (except Tasks/CancellationToken) ── [Fact] public void Sandbox_ThreadCreate_Blocked() { var result = _service.Compile("evil", """new System.Threading.Thread(() => {}).Start()"""); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Contains("System.Threading")); } [Fact] public void Sandbox_Mutex_Blocked() { var result = _service.Compile("evil", """new System.Threading.Mutex()"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_Semaphore_Blocked() { var result = _service.Compile("evil", """new System.Threading.Semaphore(1, 1)"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_TaskDelay_Allowed() { // async/await and Tasks are explicitly allowed var violations = _service.ValidateTrustModel("await System.Threading.Tasks.Task.Delay(100)"); Assert.Empty(violations); } [Fact] public void Sandbox_CancellationToken_Allowed() { var violations = _service.ValidateTrustModel( "var ct = System.Threading.CancellationToken.None;"); Assert.Empty(violations); } [Fact] public void Sandbox_CancellationTokenSource_Allowed() { var violations = _service.ValidateTrustModel( "var cts = new System.Threading.CancellationTokenSource();"); Assert.Empty(violations); } // ── Reflection forbidden ── [Fact] public void Sandbox_GetType_Reflection_Blocked() { var result = _service.Compile("evil", """typeof(string).GetMethods(System.Reflection.BindingFlags.NonPublic)"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_AssemblyLoad_Blocked() { var result = _service.Compile("evil", """System.Reflection.Assembly.Load("System.Runtime")"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_ActivatorCreateInstance_ViaReflection_Blocked() { var result = _service.Compile("evil", """System.Reflection.Assembly.GetExecutingAssembly()"""); Assert.False(result.IsSuccess); } // ── Raw network forbidden ── [Fact] public void Sandbox_TcpClient_Blocked() { var result = _service.Compile("evil", """new System.Net.Sockets.TcpClient("evil.com", 80)"""); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Contains("System.Net.Sockets")); } [Fact] public void Sandbox_UdpClient_Blocked() { var result = _service.Compile("evil", """new System.Net.Sockets.UdpClient(1234)"""); Assert.False(result.IsSuccess); } [Fact] public void Sandbox_HttpClient_Blocked() { var result = _service.Compile("evil", """new System.Net.Http.HttpClient()"""); Assert.False(result.IsSuccess); Assert.Contains(result.Errors, e => e.Contains("System.Net.Http")); } [Fact] public void Sandbox_HttpRequestMessage_Blocked() { var result = _service.Compile("evil", """new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, "https://evil.com")"""); Assert.False(result.IsSuccess); } // ── Allowed operations ── [Fact] public void Sandbox_BasicMath_Allowed() { var result = _service.Compile("safe", "Math.Max(1, 2)"); Assert.True(result.IsSuccess); } [Fact] public void Sandbox_LinqOperations_Allowed() { var result = _service.Compile("safe", "new List { 1, 2, 3 }.Where(x => x > 1).Sum()"); Assert.True(result.IsSuccess); } [Fact] public void Sandbox_StringOperations_Allowed() { var result = _service.Compile("safe", """string.Join(", ", new[] { "a", "b", "c" })"""); Assert.True(result.IsSuccess); } [Fact] public void Sandbox_DateTimeOperations_Allowed() { var result = _service.Compile("safe", "DateTime.UtcNow.AddHours(1).ToString(\"o\")"); Assert.True(result.IsSuccess); } // ── Execution cancellation ── // // These two tests used to arm a wall-clock CancellationTokenSource // (500 ms / 100 ms) and hope it expired while the script body was still // running. That is a race between a fixed timer and a fixed amount of // work, not a synchronization, and it is wrong in both directions: // // * Too slow a timer relative to the work and the script simply // FINISHES first, so nothing is cancelled and ThrowsAnyAsync fails // with "No exception was thrown". The bounded loop below was measured // at 298 ms against a 100 ms pin on this machine — a 3x margin, which // a faster host (or any change to the scripting OptimizationLevel, // which is Debug today) closes. Reproduced deterministically by // shrinking the loop to 1M iterations: it then ran in 76 ms and the // test failed with exactly that message while all 27 siblings passed. // * Too fast a timer and the token is ALREADY cancelled before the body // is entered — Roslyn's runner throws OperationCanceledException up // front (verified), so the test goes GREEN without the script's own // in-loop ThrowIfCancellationRequested ever being reached. A vacuous // pass is worse than a flake: it asserts nothing. // // Both are replaced with a deterministic edge. The script triggers the // cancellation itself, synchronously, from inside its own loop via an // Action handed in through Parameters. When that call returns, the token // is already cancelled ON THE SAME THREAD, so the next in-loop check is // guaranteed to observe it — with iterations to spare and no reliance on // wall-clock time, host speed or scheduler behaviour. Sandbox_UncancelledScript_RunsToCompletion // is the negative control for the bounded case. /// Builds globals whose "cancelNow" parameter cancels when the script invokes it. private static ScriptGlobals GlobalsWithCancelHook(CancellationTokenSource cts, Action? onSignal = null) => new() { Instance = null!, Parameters = new ScriptParameters(new Dictionary { ["cancelNow"] = onSignal ?? (() => cts.Cancel()) }), CancellationToken = cts.Token }; [Fact] public async Task Sandbox_InfiniteLoop_CancelledByToken() { // An unbounded loop that cancels itself on the first pass, so the // check on that same pass is the one that throws. var code = """ var spins = 0; while (true) { spins++; if (spins == 1) ((Action)Parameters["cancelNow"]!)(); CancellationToken.ThrowIfCancellationRequested(); } return null; """; var result = _service.Compile("infinite", code); Assert.True(result.IsSuccess, "Infinite loop compiles but should be cancelled at runtime"); using var cts = new CancellationTokenSource(); var globals = GlobalsWithCancelHook(cts); await Assert.ThrowsAnyAsync(async () => { await result.CompiledScript!.RunAsync(globals, cts.Token); }); } [Fact] public async Task Sandbox_LongRunningScript_TimesOut() { // Heavy bounded computation with periodic cancellation checks. It // cancels itself at i == 1, leaving ~100M iterations and ~10,000 // cancellation checks still ahead of it, so interruption is certain // however fast the host runs — the point being that a long script // does not get to run to completion once its deadline has fired. var code = """ var sum = 0L; for (var i = 0; i < 100_000_000; i++) { sum += i; if (i == 1) ((Action)Parameters["cancelNow"]!)(); if (i % 10000 == 0) CancellationToken.ThrowIfCancellationRequested(); } return sum; """; var result = _service.Compile("heavy", code); Assert.True(result.IsSuccess); using var cts = new CancellationTokenSource(); var globals = GlobalsWithCancelHook(cts); await Assert.ThrowsAnyAsync(async () => { await result.CompiledScript!.RunAsync(globals, cts.Token); }); } [Fact] public async Task Sandbox_UncancelledScript_RunsToCompletion() { // Negative control for Sandbox_LongRunningScript_TimesOut: the SAME // script shape, with the signal wired to a no-op instead of Cancel(). // It must run every iteration and return the closed-form sum. This is // what proves the sibling's OperationCanceledException is caused by // the cancellation and not by the loop being unreachable, the trust // model rejecting the body, or the runner failing for its own reasons. var code = """ var sum = 0L; for (var i = 0; i < 100_000_000; i++) { sum += i; if (i == 1) ((Action)Parameters["cancelNow"]!)(); if (i % 10000 == 0) CancellationToken.ThrowIfCancellationRequested(); } return sum; """; var result = _service.Compile("heavy-control", code); Assert.True(result.IsSuccess); using var cts = new CancellationTokenSource(); var signalled = false; var globals = GlobalsWithCancelHook(cts, onSignal: () => signalled = true); var value = await result.CompiledScript!.RunAsync(globals, cts.Token); Assert.True(signalled, "The script's in-loop signal must actually fire."); Assert.Equal(4999999950000000L, value.ReturnValue); } // ── Combined adversarial attempts ── [Fact] public void Sandbox_MultipleViolationsInOneScript_AllDetected() { var code = """ System.IO.File.ReadAllText("/etc/passwd"); System.Diagnostics.Process.Start("cmd"); new System.Net.Sockets.TcpClient(); new System.Net.Http.HttpClient(); """; var violations = _service.ValidateTrustModel(code); Assert.True(violations.Count >= 4, $"Expected at least 4 violations but got {violations.Count}: {string.Join("; ", violations)}"); } [Fact] public void Sandbox_UsingDirective_StillDetected() { var code = """ // Even with using aliases, the namespace string is still detected var x = System.IO.Path.GetTempPath(); """; var violations = _service.ValidateTrustModel(code); Assert.NotEmpty(violations); } }