using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; /// /// M3.1: 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). /// public class ScriptTrustValidatorTests { // ---- Reject (non-empty violations) -------------------------------------- [Fact] public void Rejects_SystemIo_Using() { var code = "using System.IO; var x = File.ReadAllText(\"/etc/passwd\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_SystemIo_GlobalQualified() { var code = "var x = global::System.IO.File.ReadAllText(\"x\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_SystemIo_Aliased() { var code = "using IO = System.IO; var f = IO.File.ReadAllText(\"x\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_SystemIo_UsingStatic() { var code = "using static System.IO.File; var s = ReadAllText(\"x\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_ReflectionGateway_OffPermittedType() { var code = "var t = typeof(string).Assembly.GetType(\"System.IO.File\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_Dynamic_Keyword() { var code = "dynamic d = 5; d.Foo();"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_Activator_CreateInstance() { var code = "var o = Activator.CreateInstance(typeof(string));"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_RuntimeInteropServices() { var code = "using System.Runtime.InteropServices; var h = Marshal.SizeOf();"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_MicrosoftWin32() { var code = "using Microsoft.Win32; var k = Registry.LocalMachine;"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_Threading_Thread_Sleep() { var code = "System.Threading.Thread.Sleep(10);"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Rejects_All_SystemNet() { var code = "System.Net.WebClient w = null;"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [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)); } [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)); } [Fact] public void Rejects_ForbiddenMutex_AsGenericArg_UnderAllowedTasksPrefix() { // System.Threading.Mutex (forbidden) appears as a generic argument of an // allowed System.Threading.Tasks.TaskCompletionSource. The allowed // outer name must not shadow the forbidden generic arg. var code = "System.Threading.Tasks.TaskCompletionSource tcs = null;"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [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)); } [Fact] public void Rejects_ForbiddenFileInfo_AsGenericArg() { // System.IO.FileInfo (forbidden) as a generic argument of an allowed // System.Collections.Generic.List. var code = "System.Collections.Generic.List x = null;"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [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)); } [Fact] public void Rejects_Process_QualifiedType() { var code = "var p = System.Diagnostics.Process.Start(\"x\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [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) ------------------------------------------- [Fact] public void Allows_TasksDelay() { var code = "await System.Threading.Tasks.Task.Delay(1);"; Assert.Empty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Allows_DiagnosticsStopwatch_NotProcess() { var code = "var sw = System.Diagnostics.Stopwatch.StartNew();"; Assert.Empty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Allows_CancellationTokenSource() { var code = "var c = new System.Threading.CancellationTokenSource();"; Assert.Empty(ScriptTrustValidator.FindViolations(code)); } [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)); } // ---- ScriptAnalysis-003: adversarial bypass-vector coverage -------------- // (a) TPA-FALLBACK DEGRADATION (the SA-001 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. [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 SA-001 fallback hole is open. var minimal = ScriptTrustPolicy.BuildMinimalFallbackReferences(); var code = "using System.Diagnostics; var p = Process.Start(\"x\");"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code, minimal)); } [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)); } [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)); } [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()` // 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. [Fact] public void Rejects_ExtensionMethod_InForbiddenNamespace() { var code = "using System.Reflection; " + "Assembly asm = typeof(string).Assembly; " + "var a = asm.GetCustomAttribute();"; 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. [Fact] public void Rejects_VerbatimIdentifier_Activator() { var code = "var o = @Activator.CreateInstance(typeof(string));"; Assert.NotEmpty(ScriptTrustValidator.FindViolations(code)); } [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). [Fact] public void Allows_BenignUnsafeBlock_NoForbiddenApi() { var code = "unsafe { int x = 1; int* p = &x; var y = *p; }"; Assert.Empty(ScriptTrustValidator.FindViolations(code)); } [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). [Fact] public void Allows_ForbiddenNamespace_InCommentOnly() { var code = "// using System.IO; File.ReadAllText(\"x\")\nvar y = 1;"; Assert.Empty(ScriptTrustValidator.FindViolations(code)); } [Fact] public void Allows_ForbiddenNamespace_InStringLiteralOnly() { var code = "var s = \"System.IO.File.ReadAllText\";"; Assert.Empty(ScriptTrustValidator.FindViolations(code)); } }