Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/SandboxTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

411 lines
16 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>
/// Script sandboxing verification.
/// Adversarial tests that verify forbidden APIs are blocked at compilation time.
/// </summary>
public class SandboxTests
{
private readonly ScriptCompilationService _service;
/// <summary>Initializes the test class with a script compilation service.</summary>
public SandboxTests()
{
_service = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance);
}
// ── System.IO forbidden ──
/// <summary>Verifies that reading a file via System.IO is blocked at compilation.</summary>
[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"));
}
/// <summary>Verifies that writing a file via System.IO is blocked at compilation.</summary>
[Fact]
public void Sandbox_FileWrite_Blocked()
{
var result = _service.Compile("evil", """System.IO.File.WriteAllText("/tmp/hack.txt", "pwned")""");
Assert.False(result.IsSuccess);
}
/// <summary>Verifies that creating a directory via System.IO is blocked at compilation.</summary>
[Fact]
public void Sandbox_DirectoryCreate_Blocked()
{
var result = _service.Compile("evil", """System.IO.Directory.CreateDirectory("/tmp/evil")""");
Assert.False(result.IsSuccess);
}
/// <summary>Verifies that using a FileStream is blocked at compilation.</summary>
[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);
}
/// <summary>Verifies that using a StreamReader is blocked at compilation.</summary>
[Fact]
public void Sandbox_StreamReader_Blocked()
{
var result = _service.Compile("evil", """new System.IO.StreamReader("/tmp/x")""");
Assert.False(result.IsSuccess);
}
// ── Process forbidden ──
/// <summary>Verifies that starting a process is blocked at compilation.</summary>
[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"));
}
/// <summary>Verifies that using ProcessStartInfo is blocked at compilation.</summary>
[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) ──
/// <summary>Verifies that creating a thread is blocked at compilation.</summary>
[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"));
}
/// <summary>Verifies that using a Mutex is blocked at compilation.</summary>
[Fact]
public void Sandbox_Mutex_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Mutex()""");
Assert.False(result.IsSuccess);
}
/// <summary>Verifies that using a Semaphore is blocked at compilation.</summary>
[Fact]
public void Sandbox_Semaphore_Blocked()
{
var result = _service.Compile("evil", """new System.Threading.Semaphore(1, 1)""");
Assert.False(result.IsSuccess);
}
/// <summary>Verifies that Task.Delay is allowed.</summary>
[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);
}
/// <summary>Verifies that using a CancellationToken is allowed.</summary>
[Fact]
public void Sandbox_CancellationToken_Allowed()
{
var violations = _service.ValidateTrustModel(
"var ct = System.Threading.CancellationToken.None;");
Assert.Empty(violations);
}
/// <summary>Verifies that using a CancellationTokenSource is allowed.</summary>
[Fact]
public void Sandbox_CancellationTokenSource_Allowed()
{
var violations = _service.ValidateTrustModel(
"var cts = new System.Threading.CancellationTokenSource();");
Assert.Empty(violations);
}
// ── Reflection forbidden ──
/// <summary>Verifies that reflection via GetType is blocked at compilation.</summary>
[Fact]
public void Sandbox_GetType_Reflection_Blocked()
{
var result = _service.Compile("evil",
"""typeof(string).GetMethods(System.Reflection.BindingFlags.NonPublic)""");
Assert.False(result.IsSuccess);
}
/// <summary>Verifies that loading an assembly is blocked at compilation.</summary>
[Fact]
public void Sandbox_AssemblyLoad_Blocked()
{
var result = _service.Compile("evil",
"""System.Reflection.Assembly.Load("System.Runtime")""");
Assert.False(result.IsSuccess);
}
/// <summary>Verifies that creating an instance via Activator reflection is blocked at compilation.</summary>
[Fact]
public void Sandbox_ActivatorCreateInstance_ViaReflection_Blocked()
{
var result = _service.Compile("evil",
"""System.Reflection.Assembly.GetExecutingAssembly()""");
Assert.False(result.IsSuccess);
}
// ── Raw network forbidden ──
/// <summary>Verifies that using a TcpClient is blocked at compilation.</summary>
[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"));
}
/// <summary>Verifies that using a UdpClient is blocked at compilation.</summary>
[Fact]
public void Sandbox_UdpClient_Blocked()
{
var result = _service.Compile("evil", """new System.Net.Sockets.UdpClient(1234)""");
Assert.False(result.IsSuccess);
}
/// <summary>Verifies that using an HttpClient is blocked at compilation.</summary>
[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"));
}
/// <summary>Verifies that using an HttpRequestMessage is blocked at compilation.</summary>
[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 ──
/// <summary>Verifies that basic math operations are allowed.</summary>
[Fact]
public void Sandbox_BasicMath_Allowed()
{
var result = _service.Compile("safe", "Math.Max(1, 2)");
Assert.True(result.IsSuccess);
}
/// <summary>Verifies that LINQ operations are allowed.</summary>
[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);
}
/// <summary>Verifies that string operations are allowed.</summary>
[Fact]
public void Sandbox_StringOperations_Allowed()
{
var result = _service.Compile("safe",
"""string.Join(", ", new[] { "a", "b", "c" })""");
Assert.True(result.IsSuccess);
}
/// <summary>Verifies that DateTime operations are allowed.</summary>
[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
};
/// <summary>Verifies that an infinite loop is cancelled by the cancellation token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
});
}
/// <summary>Verifies that a long-running script times out.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
});
}
/// <summary>Verifies that a script that is never cancelled runs to completion.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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 ──
/// <summary>Verifies that multiple violations in one script are all detected.</summary>
[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)}");
}
/// <summary>Verifies that a forbidden using directive is still detected.</summary>
[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);
}
}