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