Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs
T
Joseph Doherty ca30d17f94 fix(test): replace the SandboxTests wall-clock cancellation pins with a deterministic edge
Sandbox_LongRunningScript_TimesOut and Sandbox_InfiniteLoop_CancelledByToken
armed a CancellationTokenSource for a fixed 100 ms / 500 ms and hoped 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 FINISHES first, so
nothing is cancelled and ThrowsAnyAsync fails with "No exception was thrown".
Measured on this machine: the bounded 100M-iteration loop takes 298 ms
against the 100 ms pin — a 3x margin that a faster host closes, and that any
move off the scripting default OptimizationLevel.Debug would close outright.
Reproduced deterministically by shrinking the loop to 1M iterations: it 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 with a pre-cancelled token) — so the test goes GREEN without the
script's own in-loop ThrowIfCancellationRequested ever being reached. That
vacuous pass is the worse half: it asserts nothing while looking healthy.

Both now cancel deterministically. The script invokes an Action handed in
through Parameters from inside its own loop; 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 ~10,000 checks still ahead of it. No wall
clock, no host-speed or scheduler dependence — and the 600 ms of sleeping
goes away. Sandbox_UncancelledScript_RunsToCompletion is added as the
negative control: the same script with the signal wired to a no-op must run
every iteration and return the closed-form sum, which is what establishes
that the sibling's OCE is caused by the cancellation.

Verified: the injection that killed the old test passes with the fix, and
suppressing the cancellation entirely still fails it with the identical
message, so the claim is unchanged in force. Test-only; the sandbox's
cancellation behaviour is correct as written.
2026-08-15 03:37:18 -04:00

378 lines
13 KiB
C#

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;
/// <summary>
/// WP-6 (Phase 8): Script sandboxing verification.
/// Adversarial tests that verify forbidden APIs are blocked at compilation time.
/// </summary>
public class SandboxTests
{
private readonly ScriptCompilationService _service;
public SandboxTests()
{
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.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<int> { 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.
/// <summary>Builds globals whose "cancelNow" parameter cancels <paramref name="cts"/> when the script invokes it.</summary>
private static ScriptGlobals GlobalsWithCancelHook(CancellationTokenSource cts, Action? onSignal = null)
=> new()
{
Instance = null!,
Parameters = new ScriptParameters(new Dictionary<string, object?>
{
["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<OperationCanceledException>(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<OperationCanceledException>(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);
}
}