513fa1aa16
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.
411 lines
16 KiB
C#
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);
|
|
}
|
|
}
|