From cf935d57447bdd7403e2b18fe7ce163efac24092 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 19:40:03 -0400 Subject: [PATCH] refactor(centralui): M3.5 ScriptAnalysisService uses shared deny-list + delegates trust verdict --- .../ScriptAnalysis/ScriptAnalysisService.cs | 146 +++++++++++++----- .../ZB.MOM.WW.ScadaBridge.CentralUI.csproj | 1 + .../ScriptAnalysisServiceTests.cs | 60 ++++++- 3 files changed, 167 insertions(+), 40 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index d78fdd46..a111a93d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -12,6 +12,7 @@ using Microsoft.CodeAnalysis.Text; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using SharedTrust = ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis; @@ -56,18 +57,6 @@ public class ScriptAnalysisService "System.Text", "System.Threading.Tasks"); - // Namespaces and types banned by the script trust model. - // Tasks live under System.Threading.Tasks and remain allowed. - private static readonly string[] ForbiddenNamespacePrefixes = - { - "System.IO", - "System.Diagnostics", - "System.Reflection", - "System.Net", - "System.Threading.Thread", - "System.Threading.Tasks.Sources", - }; - private readonly ISharedScriptCatalog _sharedScripts; private readonly IMemoryCache _cache; private readonly IServiceProvider _services; @@ -1152,14 +1141,22 @@ public class ScriptAnalysisService } /// - /// Finds every reference to a forbidden API — the documented script trust model, - /// see . Identifiers are resolved against - /// the semantic model, so a forbidden type or member is caught however it is - /// written: bare (File), fully qualified - /// (System.IO.File.WriteAllText), or via an alias — while a user identifier - /// that merely shares a name with a forbidden type (var File = …) does not - /// false-positive. Used both for editor diagnostics and as the pre-execution - /// trust-model gate (see ). + /// Finds every reference to a forbidden API for the editor squiggle — the + /// documented script trust model, sourced from the shared + /// / + /// deny-list via + /// . Identifiers are resolved against the semantic + /// model, so a forbidden type or member is caught however it is written: bare + /// (File), fully qualified (System.IO.File.WriteAllText), or via an + /// alias — while a user identifier that merely shares a name with a forbidden type + /// (var File = …) does not false-positive. + /// + /// This is the position-aware, namespace-level editor advisory: it keeps the + /// line/column markers Monaco needs. The authoritative run verdict lives in + /// , which delegates to the shared + /// (and additionally covers + /// reflection-gateway members + dynamic/Activator hardening that the editor + /// advisory does not mark). /// private static IEnumerable FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model) { @@ -1208,25 +1205,45 @@ public class ScriptAnalysisService /// /// The forbidden namespace/type a symbol implicates, or null if it is allowed. /// Checks the symbol's namespace and — for a type or member — the type's full - /// name, so an entry like System.Threading.Thread bans that exact type - /// while System.Threading (e.g. CancellationToken) stays allowed. + /// name. M3.5: the allowed-exception carve-out is applied at the WHOLE-SYMBOL + /// level (mirroring the shared + /// semantic pass), so a type whose full name is an allowed exception + /// (e.g. System.Threading.CancellationTokenSource) is NOT flagged even + /// though its bare containing namespace System.Threading is a forbidden + /// root. Without the symbol-level check the namespace scope alone would + /// false-positive the allowed cancellation types. /// private static string? ForbiddenNameFor(ISymbol? symbol) { if (symbol == null) return null; - foreach (var name in QualifiedNamesOf(symbol)) - if (IsForbiddenName(name)) - return name; - return null; + var names = QualifiedNamesOf(symbol).ToList(); + if (names.Count == 0) return null; + + // Allowed exception on ANY of the symbol's qualified names wins outright — + // the rest of System.Threading stays forbidden, but Tasks + + // CancellationToken(Source) survive. + if (names.Any(n => + SharedTrust.ScriptTrustPolicy.AllowedExceptions.Any(a => IsUnderScope(n, a)))) + return null; + + return names.FirstOrDefault(IsForbiddenName); } - /// Fully-qualified names a symbol reference implicates for trust-model checking. + /// + /// Fully-qualified names a symbol reference implicates for trust-model checking. + /// M3.5: a bare namespace symbol is intentionally ignored — mirroring the shared + /// semantic pass. A namespace name + /// on its own performs no action; harm requires referencing a type or member, so + /// flagging the bare System.Threading qualifier of an otherwise-allowed + /// type (System.Threading.CancellationTokenSource) would be a false + /// positive. The forbidden-using-directive path is checked separately on + /// the directive text, so namespace imports are still caught. + /// private static IEnumerable QualifiedNamesOf(ISymbol symbol) { switch (symbol) { - case INamespaceSymbol { IsGlobalNamespace: false } ns: - yield return ns.ToDisplayString(); + case INamespaceSymbol: break; case ITypeSymbol type: if (type.ContainingNamespace is { IsGlobalNamespace: false } tn) @@ -1249,23 +1266,74 @@ public class ScriptAnalysisService ? ns.ToDisplayString() + "." + type.Name : type.Name; - private static bool IsForbiddenName(string qualifiedName) => - ForbiddenNamespacePrefixes.Any(p => - qualifiedName == p || qualifiedName.StartsWith(p + ".", StringComparison.Ordinal)); + /// + /// M3.5: the forbidden-namespace verdict for the editor walker is sourced + /// from the shared — the single + /// deny-list the run gate () also uses, so + /// the editor squiggle and the run gate agree on which namespaces are + /// forbidden. A name under a + /// root is forbidden UNLESS it is also under a + /// entry + /// (async/await + cancellation tokens survive the System.Threading block). + /// + private static bool IsForbiddenName(string qualifiedName) + { + if (SharedTrust.ScriptTrustPolicy.AllowedExceptions.Any(a => IsUnderScope(qualifiedName, a))) + return false; + return SharedTrust.ScriptTrustPolicy.ForbiddenScopes.Any(f => IsUnderScope(qualifiedName, f)); + } + + private static bool IsUnderScope(string actual, string root) => + actual.Equals(root, StringComparison.Ordinal) + || actual.StartsWith(root + ".", StringComparison.Ordinal); /// - /// Pre-execution trust-model gate (CentralUI-001). Returns the forbidden-API - /// markers (SCADA001/SCADA002) for a compiled script; an empty list means the - /// script is clear to run. This is a static semantic gate, not a process - /// sandbox — reflection-based indirection is still out of its reach; full - /// isolation would require running scripts in a separate constrained process. + /// Pre-execution trust-model gate (CentralUI-001). M3.5: the VERDICT is + /// delegated to the shared — + /// the single source of truth the editor squiggle deny-list also derives from, + /// so the run gate and the editor agree. The shared validator additionally + /// enforces reflection-gateway members + dynamic/Activator hardening beyond the + /// editor's namespace-level advisory. An empty result means clear to run. + /// + /// The compilation's own metadata references are forwarded to the shared + /// validator as extra references so its semantic pass resolves symbols against + /// the SAME metadata the script was compiled against (the full BCL surface, + /// including the SandboxScriptHost globals assembly). This keeps the gate's + /// resolution identical to the editor walker's — e.g. a bare Process + /// reached through using System.Diagnostics; resolves to + /// System.Diagnostics.Process and is caught. Forwarding references never + /// whitelists anything: the deny-list still applies regardless of what is + /// referenced. + /// + /// The shared validator returns positionless messages; the run gate doesn't + /// need precise positions (only the verdict), so each violation is surfaced as a + /// single error marker anchored at the script start. This is a static semantic + /// gate, not a process sandbox — full isolation would require a separate + /// constrained process. /// private static IReadOnlyList EnforceTrustModel(Compilation compilation) { var tree = compilation.SyntaxTrees.FirstOrDefault(); if (tree == null) return Array.Empty(); - var model = compilation.GetSemanticModel(tree); - return FindForbiddenApiUsages(tree, model).ToList(); + + var violations = SharedTrust.ScriptTrustValidator.FindViolations( + tree.ToString(), compilation.References); + if (violations.Count == 0) return Array.Empty(); + + // Positionless verdict → one error marker per violation at the script + // start. Code SCADA002 (forbidden API reference) matches the editor's + // semantic-marker code, so existing gate assertions ("SCADA001"/"SCADA002") + // continue to hold. + return violations + .Select(v => new DiagnosticMarker( + Severity: 8, + StartLineNumber: 1, + StartColumn: 1, + EndLineNumber: 1, + EndColumn: 2, + Message: $"{v} — not allowed in scripts (script trust model).", + Code: "SCADA002")) + .ToList(); } private static CompletionItem ToCompletionItem(ISymbol symbol) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj index 0632b677..49d53880 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index 798b4a90..4b7500d3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -54,11 +54,14 @@ public class ScriptAnalysisServiceTests } [Theory] - [InlineData("System.Diagnostics")] [InlineData("System.Reflection")] [InlineData("System.Net")] + [InlineData("System.Threading")] public void ForbiddenUsing_AllBannedNamespaces(string ns) { + // M3.5: the editor deny-list is sourced from the shared ScriptTrustPolicy. + // System.Threading is now a forbidden root (only Tasks + + // CancellationToken(Source) are excepted). var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};")); Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns)); } @@ -71,6 +74,61 @@ public class ScriptAnalysisServiceTests Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File")); } + [Fact] + public void SystemDiagnosticsNamespace_NoLongerWholesaleForbidden() + { + // M3.5: the unified policy forbids only System.Diagnostics.Process, not the + // whole System.Diagnostics namespace — so a bare `using System.Diagnostics;` + // is no longer flagged (Stopwatch & friends are allowed). + var resp = _svc.Diagnose(new DiagnoseRequest("using System.Diagnostics;")); + Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA001"); + } + + [Fact] + public void StopwatchUsage_IsAllowedUnderUnifiedPolicy() + { + // System.Diagnostics.Stopwatch is NOT under the forbidden + // System.Diagnostics.Process scope — it must not be flagged. + var resp = _svc.Diagnose(new DiagnoseRequest( + "using System.Diagnostics; var sw = Stopwatch.StartNew(); return sw.ElapsedMilliseconds;")); + Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002"); + } + + [Fact] + public void ProcessUsage_IsForbiddenUnderUnifiedPolicy() + { + // System.Diagnostics.Process IS the forbidden scope — flagged via the + // semantic marker even though the parent namespace is allowed. + var resp = _svc.Diagnose(new DiagnoseRequest( + "using System.Diagnostics; var p = Process.GetCurrentProcess(); return p.Id;")); + Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("Process")); + } + + [Theory] + [InlineData("Monitor")] + [InlineData("Interlocked")] + [InlineData("Mutex")] + public void ThreadingPrimitives_NowFlagged_UnderUnifiedPolicy(string typeName) + { + // CentralUI's old deny-list allowed most of System.Threading (only Thread + + // Tasks.Sources were blocked). The unified policy forbids ALL of + // System.Threading except Tasks + CancellationToken(Source), so these + // previously-clean primitives are now flagged by the editor. + var resp = _svc.Diagnose(new DiagnoseRequest( + $"using System.Threading; var x = typeof({typeName}); return 1;")); + Assert.Contains(resp.Markers, m => m.Code is "SCADA001" or "SCADA002"); + } + + [Fact] + public void CancellationToken_StaysAllowed_UnderUnifiedPolicy() + { + // The AllowedExceptions carve-out keeps CancellationToken(Source) clean even + // though the rest of System.Threading is forbidden. + var resp = _svc.Diagnose(new DiagnoseRequest( + "var cts = new System.Threading.CancellationTokenSource(); return cts.Token.IsCancellationRequested;")); + Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002"); + } + [Fact] public void UserIdentifierNamedFile_DoesNotFalsePositive() {