Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptSandboxTests.cs
Joseph Doherty 7bb21c2aa2 fix(scripting): resolve High code-review finding (Core.Scripting-002)
The ForbiddenTypeAnalyzer syntax walker only inspected four node kinds
(ObjectCreation, Invocation-with-member-access, MemberAccess, bare
Identifier), so a forbidden type named through typeof, a generic type
argument, a cast, an is/as type pattern, default(T), an array-creation
element type, or an explicitly-typed local declaration produced no
examined node and bypassed the sandbox check.

Analyze now runs a second pass that resolves GetTypeInfo on every
TypeSyntax node and recursively unwraps array element types and generic
type arguments, so forbidden types nested at any depth are rejected at
compile. The original member/call node-kind switch is kept deliberately
narrow (rather than resolving GetSymbolInfo on every node) to avoid
flagging harmless inherited members such as typeof(int).Name, whose Name
property is declared by System.Reflection.MemberInfo. A span+type dedupe
keeps the two passes from emitting duplicate rejections.

Regression tests added in ScriptSandboxTests cover typeof, generic type
arguments, casts, default(T), is/as patterns, array element types, and
typed local declarations with forbidden types, plus over-block guards
asserting allowed generics and typeof still compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 06:08:08 -04:00

389 lines
15 KiB
C#

using Microsoft.CodeAnalysis.Scripting;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
/// Locks decision #6 — scripts can't escape to the broader .NET surface.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptSandboxTests
{
[Fact]
public void Happy_path_script_compiles_and_returns()
{
// Baseline — ctx + Math + basic types must work.
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
"""
var v = (double)ctx.GetTag("X").Value;
return Math.Abs(v) * 2.0;
""");
evaluator.ShouldNotBeNull();
}
[Fact]
public async Task Happy_path_script_runs_and_reads_seeded_tag()
{
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
"""return (double)ctx.GetTag("In").Value * 2.0;""");
var ctx = new FakeScriptContext().Seed("In", 21.0);
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
result.ShouldBe(42.0);
}
[Fact]
public async Task SetVirtualTag_records_the_write()
{
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
ctx.SetVirtualTag("Out", 42);
return 0;
""");
var ctx = new FakeScriptContext();
await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
ctx.Writes.Count.ShouldBe(1);
ctx.Writes[0].Path.ShouldBe("Out");
ctx.Writes[0].Value.ShouldBe(42);
}
[Fact]
public void Rejects_File_IO_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, string>.Compile(
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
}
[Fact]
public void Rejects_HttpClient_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var c = new System.Net.Http.HttpClient();
return 0;
"""));
}
[Fact]
public void Rejects_Process_Start_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Diagnostics.Process.Start("cmd.exe");
return 0;
"""));
}
[Fact]
public void Rejects_Reflection_Assembly_Load_at_compile()
{
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Reflection.Assembly.Load("System.Core");
return 0;
"""));
}
[Fact]
public void Rejects_Environment_Exit_at_compile()
{
// System.Environment lives in System.Private.CoreLib (allow-listed for
// primitives) so a namespace-prefix deny-list cannot block it. Environment.Exit
// terminates the whole in-process OPC UA server — every connected client and
// every driver — so it MUST be rejected member-granularly. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Environment.Exit(0);
return 0;
"""));
}
[Fact]
public void Rejects_Environment_FailFast_at_compile()
{
// Environment.FailFast crashes the host process immediately — same outage as
// Exit. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Environment.FailFast("boom");
return 0;
"""));
}
[Fact]
public void Rejects_AppDomain_at_compile()
{
// AppDomain.CurrentDomain exposes process-wide control (assembly load events,
// unhandled-exception hooks). Not script surface. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var n = System.AppDomain.CurrentDomain.FriendlyName;
return 0;
"""));
}
[Fact]
public void Rejects_GC_Collect_at_compile()
{
// GC.Collect / GC.AddMemoryPressure let a script perturb the whole process's
// memory subsystem. Not script surface. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.GC.Collect();
return 0;
"""));
}
[Fact]
public void Rejects_Activator_CreateInstance_at_compile()
{
// Activator.CreateInstance is a reflection-equivalent escape — it can construct
// a forbidden type by name without ever naming it syntactically. (Core.Scripting-001.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var o = System.Activator.CreateInstance(typeof(object));
return 0;
"""));
}
[Fact]
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
{
// The whole System.Environment type is forbidden (Core.Scripting-001) — even the
// read-only GetEnvironmentVariable member. Once Exit / FailFast made the type
// dangerous, the cleanest member-granular rule is to deny the type outright; the
// read path has no legitimate use in a SCADA predicate either.
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, string?>.Compile(
"""return System.Environment.GetEnvironmentVariable("PATH");"""));
}
// --- Core.Scripting-002: type-reference node forms that bypassed the old walker ---
// The old walker only inspected ObjectCreation / Invocation-with-member-access /
// MemberAccess / bare-Identifier nodes. typeof, generic type arguments, casts,
// is/as patterns, default(T), array element types, and declared variable types all
// name a forbidden type without producing any of those nodes. The broadened walker
// resolves GetTypeInfo on every TypeSyntax / ExpressionSyntax so they are all caught.
[Fact]
public void Rejects_typeof_forbidden_type_at_compile()
{
// Phase 7 plan A.6 explicitly calls out typeof as a sandbox-escape that must
// fail at compile. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, string>.Compile(
"""return typeof(System.IO.File).Name;"""));
}
[Fact]
public void Rejects_generic_type_argument_forbidden_type_at_compile()
{
// new List<System.IO.FileInfo>() — the forbidden type is nested inside an
// allowed generic. The walker unwraps type arguments recursively. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var l = new System.Collections.Generic.List<System.IO.FileInfo>();
return l.Count;
"""));
}
[Fact]
public void Rejects_cast_to_forbidden_type_at_compile()
{
// (System.IO.Stream)null — a cast expression names the type without invoking
// or constructing it. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var s = (System.IO.Stream)null;
return 0;
"""));
}
[Fact]
public void Rejects_default_of_forbidden_type_at_compile()
{
// default(System.Reflection.Assembly) names a forbidden type via the default
// operator. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var a = default(System.Reflection.Assembly);
return 0;
"""));
}
[Fact]
public void Rejects_is_pattern_forbidden_type_at_compile()
{
// 'o is System.IO.FileStream' names a forbidden type as the pattern's type
// operand. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, bool>.Compile(
"""
object o = ctx.GetTag("X").Value;
return o is System.IO.FileStream;
"""));
}
[Fact]
public void Rejects_as_expression_forbidden_type_at_compile()
{
// 'o as System.IO.Stream' names a forbidden type via the as operator. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
object o = ctx.GetTag("X").Value;
var s = o as System.IO.Stream;
return 0;
"""));
}
[Fact]
public void Rejects_array_creation_forbidden_element_type_at_compile()
{
// new System.IO.FileInfo[0] — the forbidden type is the array element type.
// (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var a = new System.IO.FileInfo[0];
return a.Length;
"""));
}
[Fact]
public void Rejects_local_declared_variable_forbidden_type_at_compile()
{
// An explicitly-typed local declaration 'System.Net.Http.HttpClient c;' names
// a forbidden type with no construction or call. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
System.Net.Http.HttpClient c = null;
return 0;
"""));
}
[Fact]
public void Rejects_typeof_forbidden_type_inside_Activator_at_compile()
{
// Activator is already denied by ForbiddenFullTypeNames, but the typeof operand
// is independently a forbidden-type reference — confirm the typeof path catches
// it even when the Activator type itself were allowed. (Core.Scripting-002.)
Should.Throw<ScriptSandboxViolationException>(() =>
ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var t = typeof(System.Diagnostics.Process);
return 0;
"""));
}
[Fact]
public async Task Allowed_generic_type_argument_still_compiles()
{
// Guard against over-blocking: a generic with only allow-listed type arguments
// (List<int>) must still compile and run. (Core.Scripting-002.)
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var l = new System.Collections.Generic.List<int> { 1, 2, 3 };
return l.Count;
""");
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
result.ShouldBe(3);
}
[Fact]
public async Task Allowed_typeof_still_compiles()
{
// typeof against an allow-listed type (int) must not be rejected. (Core.Scripting-002.)
var evaluator = ScriptEvaluator<FakeScriptContext, string>.Compile(
"""return typeof(int).Name;""");
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
result.ShouldBe("Int32");
}
[Fact]
public async Task Script_exception_propagates_unwrapped()
{
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""throw new InvalidOperationException("boom");""");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
}
[Fact]
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
{
// Scripts that need a timestamp go through ctx.Now so tests can pin it.
var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
evaluator.ShouldNotBeNull();
}
[Fact]
public void Deadband_helper_is_reachable_from_scripts()
{
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
"""return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
evaluator.ShouldNotBeNull();
}
[Fact]
public async Task Linq_Enumerable_is_available_from_scripts()
{
// LINQ is in the allow-list because SCADA math frequently wants Sum / Average
// / Where. Confirm it works.
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
"""
var nums = new[] { 1, 2, 3, 4, 5 };
return nums.Where(n => n > 2).Sum();
""");
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
result.ShouldBe(12);
}
[Fact]
public async Task DataValueSnapshot_is_usable_in_scripts()
{
// ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
"""
var v = ctx.GetTag("T");
return v.StatusCode == 0;
""");
var ctx = new FakeScriptContext().Seed("T", 5.0);
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
result.ShouldBeTrue();
}
[Fact]
public void Compile_error_gives_location_in_diagnostics()
{
// Compile errors must carry the source span so the Admin UI can point at them.
try
{
ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
Assert.Fail("expected CompilationErrorException");
}
catch (CompilationErrorException ex)
{
ex.Diagnostics.ShouldNotBeEmpty();
ex.Diagnostics[0].Location.ShouldNotBeNull();
}
}
}