d0af884760
MES alarm-status API §5.2 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 tasks 2-4). Site `Call` scripts had NO way to read alarm condition
state: the `Alarm` global exists only inside an on-trigger handler and
describes the one alarm that fired, and native mirrored conditions were
reachable only from the Debug View. That gap blocked the CvdReactor
SimpleAlarmStatus/AlarmStatus scripts entirely -- they cannot be written
without it. `Alarms.CurrentAsync()` closes it.
The data was already local: the script runs inside its own Instance Actor's
context, so this is a LOCAL Ask -- the same mechanism attribute reads use, no
cross-cluster hop. A dedicated GetAlarmSnapshotRequest/Response is used rather
than reusing DebugSnapshotRequest, which would materialise every attribute
value on every alarm poll; both are served from the same
BuildAlarmStatesSnapshot(), so the script view and the operator's Debug View
can never disagree.
Deliberate shape decisions:
- NOT scope-prefixed, unlike Attributes. Alarm identity is not a
scope-relative attribute name (computed alarms are keyed by configured
name, native conditions by a source-supplied reference), so prefixing
would hand a composed script a silently truncated list.
- Read-only. Native alarms are a read-only mirror of the source (no
ack-back), so no acknowledge/shelve operation is exposed.
- Placeholder rows are NOT pre-filtered: a caller must be able to tell
"binding configured and quiet" from "binding unknown". The documented
filter is `Active && !IsConfiguredPlaceholder`.
- ScriptAlarm lives in Commons so the runtime accessor and the compile-only
surface project to the SAME type -- a script binding at the design-time
gate binds identically at the site. Condition is the authority for
active/acked/severity, so one filter expression works across computed and
native alarms.
Mirrored on BOTH design-time surfaces. ScriptCompileSurface is covered by the
reflection parity guard (AlarmsAccessor added to its mirror pairs). The Central
UI Test-Run SandboxScriptHost is the third, hand-maintained mirror that the
parity test cannot reach (Central UI does not reference Site Runtime); without
it the design page would false-flag CS1061 on scripts the deploy gate accepts.
It throws a labelled ScriptSandboxException at run time -- there is no central
route to per-instance alarm state, and returning an empty list would read as
"nothing is in alarm", which is worse than an error.
ScriptTrustPolicy needs NO change, and the reason is structural rather than
incidental: the trust boundary is a deny-list over API roots, not an allow-list
of context members. A test pins that no ForbiddenScopes entry prefixes the
Commons script-surface namespace, so a future deny-list entry cannot silently
make ScriptAlarm untouchable.
Tests: 6 accessor cases (Ask contract, full native projection incl. AckTime,
unacked, computed-alarm derivation, placeholder visibility, scope-independence),
2 InstanceActor snapshot cases incl. equality with the Debug View row set, the
full MES script shape compiling against ScriptCompileSurface, 2 trust cases,
and a sandbox diagnose-clean case reading every projected ScriptAlarm field.
380 lines
16 KiB
C#
380 lines
16 KiB
C#
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
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<int>();";
|
|
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<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));
|
|
}
|
|
|
|
[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<T>.
|
|
var code = "System.Collections.Generic.List<System.IO.FileInfo> 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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
// ---- 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<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.
|
|
[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.
|
|
[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));
|
|
}
|
|
|
|
// ---- PLAN-05 Task 21: 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.
|
|
[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.
|
|
[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.
|
|
[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));
|
|
}
|