Phase 8: Production readiness — failover tests, security hardening, sandboxing, deployment docs
- 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.
This commit is contained in:
307
tests/ScadaLink.SiteRuntime.Tests/Scripts/SandboxTests.cs
Normal file
307
tests/ScadaLink.SiteRuntime.Tests/Scripts/SandboxTests.cs
Normal file
@@ -0,0 +1,307 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user