Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.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

464 lines
22 KiB
C#

using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
/// <summary>
/// Adversarial + legitimate cases for the fused trust validator. Reject
/// cases exercise the semantic pass (alias / using static / global::), the
/// reflection-gateway hardening pass, and the namespace deny-list union; clean
/// cases pin the allowed exceptions (Tasks, CancellationToken, Diagnostics
/// other than Process).
/// </summary>
public class ScriptTrustValidatorTests
{
/// <summary>Verifies that runtime escape APIs are rejected.</summary>
/// <param name="code">The script source containing a runtime escape API call.</param>
[Theory]
[InlineData("object o = new object(); System.Runtime.CompilerServices.Unsafe.As<object, string>(ref o);")]
[InlineData("System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(typeof(object));")]
[InlineData("new System.Runtime.Loader.AssemblyLoadContext(\"probe\");")]
[InlineData("using U = System.Runtime.CompilerServices.Unsafe; object o = new object(); U.As<object, string>(ref o);")]
[InlineData("using static System.Runtime.CompilerServices.RuntimeHelpers; GetUninitializedObject(typeof(object));")]
public void MG1_RejectsRuntimeEscapeApis(string code)
{
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that compiler-support constructs are allowed.</summary>
/// <param name="code">The script source using compiler-support APIs.</param>
[Theory]
[InlineData("await System.Threading.Tasks.Task.Delay(1);")]
[InlineData("string Name([System.Runtime.CompilerServices.CallerMemberName] string name = \"\") => name;")]
public void MG1_AllowsCompilerSupport(string code)
{
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
// ---- Reject (non-empty violations) --------------------------------------
/// <summary>Verifies that a <c>using</c> of the System.IO namespace is rejected.</summary>
[Fact]
public void Rejects_SystemIo_Using()
{
var code = "using System.IO; var x = File.ReadAllText(\"/etc/passwd\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a global-qualified System.IO reference is rejected.</summary>
[Fact]
public void Rejects_SystemIo_GlobalQualified()
{
var code = "var x = global::System.IO.File.ReadAllText(\"x\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that an aliased System.IO reference is rejected.</summary>
[Fact]
public void Rejects_SystemIo_Aliased()
{
var code = "using IO = System.IO; var f = IO.File.ReadAllText(\"x\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a <c>using static</c> of a System.IO type is rejected.</summary>
[Fact]
public void Rejects_SystemIo_UsingStatic()
{
var code = "using static System.IO.File; var s = ReadAllText(\"x\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that reflection-gateway members are rejected on a permitted type.</summary>
[Fact]
public void Rejects_ReflectionGateway_OffPermittedType()
{
var code = "var t = typeof(string).Assembly.GetType(\"System.IO.File\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that the <c>dynamic</c> keyword is rejected.</summary>
[Fact]
public void Rejects_Dynamic_Keyword()
{
var code = "dynamic d = 5; d.Foo();";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that <c>Activator.CreateInstance</c> is rejected.</summary>
[Fact]
public void Rejects_Activator_CreateInstance()
{
var code = "var o = Activator.CreateInstance(typeof(string));";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that System.Runtime.InteropServices usage is rejected.</summary>
[Fact]
public void Rejects_RuntimeInteropServices()
{
var code = "using System.Runtime.InteropServices; var h = Marshal.SizeOf<int>();";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that Microsoft.Win32 usage is rejected.</summary>
[Fact]
public void Rejects_MicrosoftWin32()
{
var code = "using Microsoft.Win32; var k = Registry.LocalMachine;";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that <c>Thread.Sleep</c> is rejected.</summary>
[Fact]
public void Rejects_Threading_Thread_Sleep()
{
var code = "System.Threading.Thread.Sleep(10);";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that all System.Net usage is rejected.</summary>
[Fact]
public void Rejects_All_SystemNet()
{
var code = "System.Net.WebClient w = null;";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that reflection-gateway members are rejected on an allowed Task type.</summary>
[Fact]
public void Rejects_ReflectionGateway_OffAllowedTaskType()
{
// Guards the reflection-first ordering: a gateway member hung off an
// allowed System.Threading.Tasks type must still be rejected even though
// the chain's namespace prefix is an allowed exception.
var code = "var a = System.Threading.Tasks.Task.CompletedTask.GetType().Assembly;";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that forbidden IO nested inside an allowed <c>Task.Run</c> lambda is rejected.</summary>
[Fact]
public void Rejects_ForbiddenIo_NestedInAllowedTaskRunLambda()
{
// A forbidden System.IO reference buried inside an allowed Task.Run lambda.
// The allowed-exception prefix on the outer member access must NOT shadow
// the nested forbidden reference — Pass 2 must descend into the lambda.
var code = "await System.Threading.Tasks.Task.Run(() => System.IO.File.ReadAllText(\"x\"));";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a forbidden mutex used as a generic argument under an allowed Tasks prefix is rejected.</summary>
[Fact]
public void Rejects_ForbiddenMutex_AsGenericArg_UnderAllowedTasksPrefix()
{
// System.Threading.Mutex (forbidden) appears as a generic argument of an
// allowed System.Threading.Tasks.TaskCompletionSource<T>. The allowed
// outer name must not shadow the forbidden generic arg.
var code = "System.Threading.Tasks.TaskCompletionSource<System.Threading.Mutex> tcs = null;";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a direct threading mutex is rejected independently of <c>Thread.Sleep</c>.</summary>
[Fact]
public void Rejects_DirectThreadingMutex_NotThreadSleep()
{
// A direct forbidden System.Threading type (not Thread.Sleep) — pins that
// the broad System.Threading deny-list catches more than the one cased test.
var code = "var m = new System.Threading.Mutex();";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that <c>FileInfo</c> used as a generic argument is rejected.</summary>
[Fact]
public void Rejects_ForbiddenFileInfo_AsGenericArg()
{
// System.IO.FileInfo (forbidden) as a generic argument of an allowed
// System.Collections.Generic.List<T>.
var code = "System.Collections.Generic.List<System.IO.FileInfo> x = null;";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that <c>nameof</c> applied to a forbidden type is rejected.</summary>
[Fact]
public void Rejects_NameOf_ForbiddenType()
{
// Conservative fail-safe: naming a forbidden type inside nameof(...) is
// deliberately flagged (a script has no business naming it even there).
var code = "var s = nameof(System.IO.File);";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a qualified <c>Process</c> type reference is rejected.</summary>
[Fact]
public void Rejects_Process_QualifiedType()
{
var code = "var p = System.Diagnostics.Process.Start(\"x\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a bare <c>Process</c> identifier introduced via a <c>using</c> is rejected.</summary>
[Fact]
public void Rejects_Process_BareIdentifier_ViaUsing()
{
// System.Diagnostics is an ALLOWED namespace (Stopwatch/Debug ok), so the
// using directive is not flagged; Process is a forbidden TYPE reached as a
// bare identifier. This pins whether FindViolations resolves it.
var code = "using System.Diagnostics; var p = Process.Start(\"x\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
// ---- Clean (empty violations) -------------------------------------------
/// <summary>Verifies that <c>Task.Delay</c> is allowed.</summary>
[Fact]
public void Allows_TasksDelay()
{
var code = "await System.Threading.Tasks.Task.Delay(1);";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that <c>Stopwatch</c> from System.Diagnostics is allowed while <c>Process</c> is not.</summary>
[Fact]
public void Allows_DiagnosticsStopwatch_NotProcess()
{
var code = "var sw = System.Diagnostics.Stopwatch.StartNew();";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that <c>CancellationTokenSource</c> is allowed.</summary>
[Fact]
public void Allows_CancellationTokenSource()
{
var code = "var c = new System.Threading.CancellationTokenSource();";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that LINQ and <c>Math</c> usage is allowed.</summary>
[Fact]
public void Allows_LinqAndMath()
{
var code = "var n = System.Linq.Enumerable.Range(0,3).Sum(); var m = System.Math.Max(1,2);";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that <c>Alarms.CurrentAsync</c> and the <c>ScriptAlarm</c> type are allowed.</summary>
[Fact]
public void Allows_AlarmsCurrentAsync_AndTheScriptAlarmType()
{
// MES alarm-status API §5.2/task 4. The trust model is a DENY-list over API roots,
// not an allow-list of context members, so `Alarms` needs no policy entry — but the
// ScriptAlarm projection it returns lives in Commons, which must resolve as a
// permitted namespace. This pins that: a script naming the type explicitly (not just
// via `var`) produces no violation.
const string code = """
var alarms = await Alarms.CurrentAsync();
System.Collections.Generic.IReadOnlyList<
ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptAlarm> typed = alarms;
var acked = typed.Where(a => a.AckTime != null).Select(a => a.Name).ToList();
""";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that the <c>Log</c> global is allowed.</summary>
[Fact]
public void Allows_TheLogGlobal()
{
// Flow Debugger step logs (design 2026-09-09 D4). The trust model is a DENY-list
// over API roots, not an allow-list of context members, so the `Log` global needs
// no policy entry — including the overload that attaches a System.Exception, which
// is a permitted BCL type. This pins that a logging script is not blocked.
const string code = """
Log.Info("x");
Log.Debug("d");
Log.Warn("w", new System.Exception("boom"));
Log.Error("e");
Instance.Log.Info("via instance");
""";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that the forbidden scopes do not cover the Commons script surface.</summary>
[Fact]
public void ForbiddenScopes_DoNotCover_TheCommonsScriptSurface()
{
// Guards the above from the other direction: if someone ever adds a deny-list root
// that swallows the ZB.MOM.WW namespace, every script accessor's return type would
// become a trust violation. Fail loudly here rather than at deploy time.
Assert.DoesNotContain(
ScriptTrustPolicy.ForbiddenScopes,
scope => typeof(ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts.ScriptAlarm)
.Namespace!.StartsWith(scope, StringComparison.Ordinal));
}
// ---- Adversarial bypass-vector coverage --------------
// (a) TPA-FALLBACK DEGRADATION (the original bypass hole). Forces Pass 1 onto the
// minimal fallback reference set (DefaultAssemblies + ForbiddenAnchorAssemblies)
// — the set used on a single-file/AOT/trimmed host with no TPA list — and
// proves a BARE forbidden type inside an ALLOWED namespace is STILL caught.
// Before the fix, `Process` resolved to nothing against the minimal set, the
// syntactic fallback ignored the dotless identifier, and Pass 2 never flags a
// bare identifier — so `Process.Start` slipped the validator entirely. The
// anchor assemblies folded into the fallback close that hole.
/// <summary>Verifies that the minimal fallback reference set still rejects a bare <c>Process</c> introduced via a <c>using</c>.</summary>
[Fact]
public void TpaFallback_StillRejects_BareProcess_ViaUsing()
{
// The documented forbidden-type-in-allowed-namespace case: System.Diagnostics
// is allowed (Stopwatch/Debug), the `using` is not flagged, and `Process`
// is a BARE identifier. Against the minimal fallback set this must still
// be rejected — otherwise the fallback hole is open.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var code = "using System.Diagnostics; var p = Process.Start(\"x\");";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code, minimal));
}
/// <summary>Verifies that the minimal fallback reference set still rejects a bare <c>Socket</c> introduced via a <c>using</c>.</summary>
[Fact]
public void TpaFallback_StillRejects_BareSocket_ViaUsing()
{
// System.Net.Sockets.Socket lives in its own assembly (not CoreLib); the
// anchor set must include it so the minimal fallback still resolves a bare
// `Socket` reference.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var code = "using System.Net.Sockets; Socket s = null;";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code, minimal));
}
/// <summary>Verifies that the minimal fallback reference set still allows <c>Stopwatch</c>.</summary>
[Fact]
public void TpaFallback_StillAllows_DiagnosticsStopwatch()
{
// Control: the fallback must not over-block — Stopwatch (System.Diagnostics,
// allowed) stays clean even against the minimal anchor-enriched set.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var code = "var sw = System.Diagnostics.Stopwatch.StartNew();";
Assert.Empty(ScriptTrustValidator.FindViolations(code, minimal));
}
/// <summary>Verifies that the minimal fallback references resolve <c>Process</c> as a forbidden type.</summary>
[Fact]
public void MinimalFallbackReferences_Resolve_Process_AsForbidden()
{
// Pins the resolution mechanism directly: against the minimal fallback set,
// bare `Process` resolves to its true namespace and is reported by the
// semantic pass (the message names the forbidden scope), not merely caught
// by some incidental syntactic rule.
var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences();
var violations = ScriptTrustValidator.FindViolations(
"using System.Diagnostics; var p = Process.Start(\"x\");", minimal);
Assert.Contains(violations, v => v.Contains("System.Diagnostics.Process", StringComparison.Ordinal));
}
// (b) EXTENSION-METHOD invocation of a forbidden API. `asm.GetCustomAttribute<T>()`
// resolves to the extension method on System.Reflection.CustomAttributeExtensions
// (a forbidden namespace) even though it is invoked in receiver position — the
// semantic pass resolves the reduced extension method's containing type, so the
// forbidden namespace is caught through the invocation itself.
/// <summary>Verifies that an extension method from a forbidden namespace is rejected.</summary>
[Fact]
public void Rejects_ExtensionMethod_InForbiddenNamespace()
{
var code =
"using System.Reflection; " +
"Assembly asm = typeof(string).Assembly; " +
"var a = asm.GetCustomAttribute<System.ObsoleteAttribute>();";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
// (c) VERBATIM (@) and UNICODE-ESCAPE spellings of a forbidden identifier.
// VisitIdentifierName compares Identifier.ValueText, which decodes both the
// verbatim '@' prefix and \uXXXX escapes — so neither spelling evades the
// ForbiddenIdentifiers deny-list.
/// <summary>Verifies that a verbatim-identifier <c>Activator</c> reference is rejected.</summary>
[Fact]
public void Rejects_VerbatimIdentifier_Activator()
{
var code = "var o = @Activator.CreateInstance(typeof(string));";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a Unicode-escaped <c>Activator</c> identifier is rejected.</summary>
[Fact]
public void Rejects_UnicodeEscapedIdentifier_Activator()
{
// 'A' is 'A' — the token spells "Activator" but ValueText is "Activator".
var code = "var o = \\u0041ctivator.CreateInstance(typeof(string));";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
// (d) UNSAFE block. The validator is a forbidden-API deny-list, not a
// language-feature gate: a benign `unsafe` block reaches no forbidden API, so
// it must NOT be a false positive — while a forbidden API used INSIDE an unsafe
// block is still caught (the walker descends into the block).
/// <summary>Verifies that an unsafe block without forbidden APIs is allowed.</summary>
[Fact]
public void Allows_BenignUnsafeBlock_NoForbiddenApi()
{
var code = "unsafe { int x = 1; int* p = &x; var y = *p; }";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a forbidden API inside an unsafe block is rejected.</summary>
[Fact]
public void Rejects_ForbiddenApi_InsideUnsafeBlock()
{
var code = "unsafe { var t = typeof(string).Assembly; }";
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
}
// (e) COMMENT / STRING-LITERAL must NOT cause a false positive. A forbidden
// namespace mentioned only in trivia or a string literal reaches no API and
// must stay clean (the walker inspects name/member nodes, never trivia or
// literal text). Reconstructing a forbidden API from runtime strings is outside
// the static validator's remit (documented sandbox caveat).
/// <summary>Verifies that a forbidden namespace mentioned only in a comment is allowed.</summary>
[Fact]
public void Allows_ForbiddenNamespace_InCommentOnly()
{
var code = "// using System.IO; File.ReadAllText(\"x\")\nvar y = 1;";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
/// <summary>Verifies that a forbidden namespace mentioned only in a string literal is allowed.</summary>
[Fact]
public void Allows_ForbiddenNamespace_InStringLiteralOnly()
{
var code = "var s = \"System.IO.File.ReadAllText\";";
Assert.Empty(ScriptTrustValidator.FindViolations(code));
}
// ---- Hardened deny-list additions -----------------------
// Environment (whole type), GC, and the concrete ADO.NET provider namespaces
// (Microsoft.Data / System.Data.SqlClient / .Odbc / .OleDb) are now forbidden.
/// <summary>Verifies that scripts using hardened deny-list APIs are flagged as violations.</summary>
/// <param name="code">The script source expected to be flagged.</param>
[Theory]
[InlineData("Environment.Exit(0);")]
[InlineData("Environment.FailFast(\"x\");")]
[InlineData("var s = Environment.GetEnvironmentVariable(\"SCADABRIDGE_API_KEY\");")]
[InlineData("var c = new Microsoft.Data.SqlClient.SqlConnection(\"Server=attacker\");")]
public void FindViolations_Flags(string code) => Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
// Syntactic gateway closure (matters in TPA-degraded fallback mode): these
// reflection-gateway members are rejected regardless of receiver.
/// <summary>Verifies that the syntactic pass flags reflection-gateway members regardless of receiver.</summary>
/// <param name="code">The script source expected to be flagged.</param>
[Theory]
[InlineData("typeof(string).Assembly.GetTypes()")]
[InlineData("asm.EntryPoint")]
public void SyntacticPass_Flags(string code) => Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
// Must NOT regress legitimate scripts: Task.Delay stays allowed and
// delegate.Invoke() is deliberately NOT on the reflection-gateway list.
/// <summary>Verifies that legitimate scripts are not flagged as violations.</summary>
/// <param name="code">The script source expected to be allowed.</param>
[Theory]
[InlineData("await Task.Delay(1);")]
[InlineData("Func<int,int> f = x => x; f.Invoke(3);")]
public void FindViolations_Allows(string code) => Assert.Empty(ScriptTrustValidator.FindViolations(code));
}