- WP-1-3: Central/site failover + dual-node recovery tests (17 tests) - WP-4: Performance testing framework for target scale (7 tests) - WP-5: Security hardening (LDAPS, JWT key length, no secrets in logs) (11 tests) - WP-6: Script sandboxing adversarial tests (28 tests, all forbidden APIs) - WP-7: Recovery drill test scaffolds (5 tests) - WP-8: Observability validation (structured logs, correlation IDs, metrics) (6 tests) - WP-9: Message contract compatibility (forward/backward compat) (18 tests) - WP-10: Deployment packaging (installation guide, production checklist, topology) - WP-11: Operational runbooks (failover, troubleshooting, maintenance) 92 new tests, all passing. Zero warnings.
308 lines
9.2 KiB
C#
308 lines
9.2 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ScadaLink.SiteRuntime.Scripts;
|
|
|
|
namespace ScadaLink.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 timeout ──
|
|
|
|
[Fact]
|
|
public async Task Sandbox_InfiniteLoop_CancelledByToken()
|
|
{
|
|
// Compile a script that loops forever
|
|
var code = """
|
|
while (true) {
|
|
CancellationToken.ThrowIfCancellationRequested();
|
|
}
|
|
return null;
|
|
""";
|
|
|
|
var result = _service.Compile("infinite", code);
|
|
Assert.True(result.IsSuccess, "Infinite loop compiles but should be cancelled at runtime");
|
|
|
|
// Execute with a short timeout
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
|
|
var globals = new ScriptGlobals
|
|
{
|
|
Instance = null!,
|
|
Parameters = new Dictionary<string, object?>(),
|
|
CancellationToken = cts.Token
|
|
};
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
|
{
|
|
await result.CompiledScript!.RunAsync(globals, cts.Token);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Sandbox_LongRunningScript_TimesOut()
|
|
{
|
|
// A script that does heavy computation with cancellation checks
|
|
var code = """
|
|
var sum = 0;
|
|
for (var i = 0; i < 100_000_000; i++) {
|
|
sum += i;
|
|
if (i % 10000 == 0) CancellationToken.ThrowIfCancellationRequested();
|
|
}
|
|
return sum;
|
|
""";
|
|
|
|
var result = _service.Compile("heavy", code);
|
|
Assert.True(result.IsSuccess);
|
|
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
|
|
var globals = new ScriptGlobals
|
|
{
|
|
Instance = null!,
|
|
Parameters = new Dictionary<string, object?>(),
|
|
CancellationToken = cts.Token
|
|
};
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
|
{
|
|
await result.CompiledScript!.RunAsync(globals, cts.Token);
|
|
});
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
}
|