From 4f2b17ce6d4d37225c8f76bf164e08e8a6537979 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 19:18:39 -0400 Subject: [PATCH] feat(scriptanalysis): M3.1 shared trust validator + compiler + compile surfaces + tests --- ZB.MOM.WW.ScadaBridge.slnx | 2 + .../RoslynScriptCompiler.cs | 83 ++++ .../ScriptCompileSurface.cs | 212 ++++++++++ .../ScriptTrustPolicy.cs | 134 +++++++ .../ScriptTrustValidator.cs | 369 ++++++++++++++++++ .../TriggerCompileSurface.cs | 51 +++ ...B.MOM.WW.ScadaBridge.ScriptAnalysis.csproj | 19 + .../RoslynScriptCompilerTests.cs | 72 ++++ .../ScriptTrustValidatorTests.cs | 132 +++++++ ...WW.ScadaBridge.ScriptAnalysis.Tests.csproj | 26 ++ 10 files changed, 1100 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/RoslynScriptCompiler.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/TriggerCompileSurface.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj diff --git a/ZB.MOM.WW.ScadaBridge.slnx b/ZB.MOM.WW.ScadaBridge.slnx index 90ed56aa..c62d9096 100644 --- a/ZB.MOM.WW.ScadaBridge.slnx +++ b/ZB.MOM.WW.ScadaBridge.slnx @@ -23,6 +23,7 @@ + @@ -51,5 +52,6 @@ + diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/RoslynScriptCompiler.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/RoslynScriptCompiler.cs new file mode 100644 index 00000000..8203e5ca --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/RoslynScriptCompiler.cs @@ -0,0 +1,83 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +/// +/// M3.1: the single authoritative Roslyn compile gate. Ported from the +/// SiteRuntime ScriptCompilationService.CompileCore, but returns +/// diagnostic messages rather than a compiled Script delegate — this is +/// the design-time gate (the deploy-time validation that previously relied on +/// the FAKE substring + brace-balance check in +/// TemplateEngine/Validation/ScriptCompiler.cs), which needs to know +/// whether the script compiles, not to execute it. +/// +public static class RoslynScriptCompiler +{ + /// + /// Parses the script as C# script source and returns syntax-error diagnostic + /// messages (severity Error only). Empty list means the script parses. + /// + /// The C# script source to parse. + /// Error-severity parse diagnostic messages; empty if the script parses. + public static IReadOnlyList ParseDiagnostics(string code) + { + var tree = CSharpSyntaxTree.ParseText( + code, new CSharpParseOptions(kind: SourceCodeKind.Script)); + + return tree.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.GetMessage()) + .ToList(); + } + + /// + /// Compiles the script against the trust-model references and imports and + /// returns error-severity compilation diagnostic messages. Empty list means + /// the script compiles cleanly against . + /// + /// The C# script source to compile. + /// + /// Optional globals type the script binds against — e.g. + /// ScriptCompileSurface for instance/shared scripts or + /// TriggerCompileSurface for trigger expressions. + /// + /// Optional additional metadata references. + /// Optional additional namespace imports. + /// Error-severity compile diagnostic messages; empty if the script compiles. + public static IReadOnlyList Compile( + string code, + Type? globalsType = null, + IEnumerable? extraReferences = null, + IEnumerable? extraImports = null) + { + try + { + var references = ScriptTrustPolicy.DefaultReferences.ToList(); + if (extraReferences != null) + references.AddRange(extraReferences); + + var imports = ScriptTrustPolicy.DefaultImports.AsEnumerable(); + if (extraImports != null) + imports = imports.Concat(extraImports); + + var options = ScriptOptions.Default + .WithReferences(references) + .WithImports(imports); + + var script = CSharpScript.Create(code, options, globalsType); + var diagnostics = script.Compile(); + + return diagnostics + .Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => d.GetMessage()) + .ToList(); + } + catch (Exception ex) + { + return [$"Compilation exception: {ex.Message}"]; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs new file mode 100644 index 00000000..77e4fecf --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptCompileSurface.cs @@ -0,0 +1,212 @@ +using System.Data.Common; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts; + +namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +/// +/// M3.1: a compile-only globals type that mirrors the real SiteRuntime +/// ScriptGlobals (+ ScriptRuntimeContext helper surface) +/// member-for-member, so a real instance / shared / on-trigger-handler script +/// BINDS against it at design time. It is NEVER executed — every member body +/// throws or returns default. The +/// design-time deploy gate (M3.5) compiles candidate scripts against this type +/// via +/// to catch undefined symbols and signature mismatches without touching the +/// site runtime. +/// +/// +/// Keeping this surface faithful is enforced by the +/// RoslynScriptCompilerTests "representative real script" corpus — if a +/// member or signature drifts from the runtime ScriptGlobals, that test +/// fails. +/// +/// +public sealed class ScriptCompileSurface +{ + private const string CompileOnly = "compile-only surface"; + + /// Mirrors ScriptGlobals.Instance. + public CompileInstance Instance { get; set; } = null!; + + /// Mirrors ScriptGlobals.Parameters. + public ScriptParameters Parameters { get; set; } = new(); + + /// Mirrors ScriptGlobals.CancellationToken. + public CancellationToken CancellationToken { get; set; } + + /// Mirrors ScriptGlobals.Alarm. + public AlarmContext? Alarm { get; set; } + + /// Mirrors ScriptGlobals.Scope. + public ScriptScope Scope { get; set; } = ScriptScope.Root; + + /// Mirrors ScriptGlobals.ExternalSystem (delegates to Instance). + public CompileExternalSystem ExternalSystem => Instance.ExternalSystem; + + /// Mirrors ScriptGlobals.Database. + public CompileDatabase Database => Instance.Database; + + /// Mirrors ScriptGlobals.Notify. + public CompileNotify Notify => Instance.Notify; + + /// Mirrors ScriptGlobals.Scripts. + public CompileScripts Scripts => Instance.Scripts; + + /// Mirrors ScriptGlobals.Attributes. + public CompileAttributeAccessor Attributes => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptGlobals.Children. + public CompileChildrenAccessor Children => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptGlobals.Parent. + public CompileCompositionAccessor? Parent => throw new NotSupportedException(CompileOnly); + + /// Compile-only mirror of ScriptRuntimeContext (the Instance global). + public sealed class CompileInstance + { + /// Mirrors ScriptRuntimeContext.GetAttribute. + public Task GetAttribute(string attributeName) => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.SetAttribute. + public Task SetAttribute(string attributeName, string value) => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.CallScript. + public Task CallScript(string scriptName, object? parameters = null) => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.ExternalSystem. + public CompileExternalSystem ExternalSystem => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.Database. + public CompileDatabase Database => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.Notify. + public CompileNotify Notify => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.Scripts. + public CompileScripts Scripts => throw new NotSupportedException(CompileOnly); + + /// Mirrors ScriptRuntimeContext.Tracking. + public CompileTracking Tracking => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of ScriptRuntimeContext.ExternalSystemHelper. + public sealed class CompileExternalSystem + { + /// Mirrors ExternalSystemHelper.Call. + public Task Call( + string systemName, + string methodName, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + + /// Mirrors ExternalSystemHelper.CachedCall. + public Task CachedCall( + string systemName, + string methodName, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of ScriptRuntimeContext.DatabaseHelper. + public sealed class CompileDatabase + { + /// Mirrors DatabaseHelper.Connection. + public Task Connection(string name, CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + + /// Mirrors DatabaseHelper.CachedWrite. + public Task CachedWrite( + string name, + string sql, + IReadOnlyDictionary? parameters = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of ScriptRuntimeContext.NotifyHelper. + public sealed class CompileNotify + { + /// Mirrors NotifyHelper.To. + public CompileNotifyTarget To(string listName) => throw new NotSupportedException(CompileOnly); + + /// Mirrors NotifyHelper.Status. + public Task Status(string notificationId) => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of ScriptRuntimeContext.NotifyTarget. + public sealed class CompileNotifyTarget + { + /// Mirrors NotifyTarget.Send. + public Task Send(string subject, string message, CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of ScriptRuntimeContext.ScriptCallHelper. + public sealed class CompileScripts + { + /// Mirrors ScriptCallHelper.CallShared. + public Task CallShared( + string scriptName, + object? parameters = null, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of ScriptRuntimeContext.TrackingHelper. + public sealed class CompileTracking + { + /// Mirrors TrackingHelper.Status. + public Task Status( + TrackedOperationId trackedOperationId, + CancellationToken cancellationToken = default) + => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of AttributeAccessor. + public sealed class CompileAttributeAccessor + { + /// Mirrors AttributeAccessor.this[string]. + public object? this[string key] + { + get => throw new NotSupportedException(CompileOnly); + set => throw new NotSupportedException(CompileOnly); + } + + /// Mirrors AttributeAccessor.GetAsync. + public Task GetAsync(string key) => throw new NotSupportedException(CompileOnly); + + /// Mirrors AttributeAccessor.SetAsync. + public Task SetAsync(string key, object? value) => throw new NotSupportedException(CompileOnly); + + /// Mirrors AttributeAccessor.Resolve. + public string Resolve(string key) => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of ChildrenAccessor. + public sealed class CompileChildrenAccessor + { + /// Mirrors ChildrenAccessor.this[string]. + public CompileCompositionAccessor this[string compositionName] => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of CompositionAccessor. + public sealed class CompileCompositionAccessor + { + /// Mirrors CompositionAccessor.Attributes. + public CompileAttributeAccessor Attributes => throw new NotSupportedException(CompileOnly); + + /// Mirrors CompositionAccessor.CallScript. + public Task CallScript(string scriptName, object? parameters = null) => throw new NotSupportedException(CompileOnly); + + /// Mirrors CompositionAccessor.ResolveScript. + public string ResolveScript(string scriptName) => throw new NotSupportedException(CompileOnly); + + /// Mirrors CompositionAccessor.Path. + public string Path => throw new NotSupportedException(CompileOnly); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs new file mode 100644 index 00000000..c819ae1b --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs @@ -0,0 +1,134 @@ +using System.Reflection; +using Microsoft.CodeAnalysis; +using ZB.MOM.WW.ScadaBridge.Commons.Types; + +namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +/// +/// M3.1: the single authoritative source of truth for the ScadaBridge script +/// trust model. Previously the forbidden-API deny-list, allowed exceptions, +/// reflection-gateway member names, default metadata references, and default +/// imports were duplicated (and disagreed) across four call sites — the +/// SiteRuntime ScriptCompilationService, the InboundAPI +/// ForbiddenApiChecker, and the design-time deploy gate. This class +/// fuses them into one collection set that +/// and consume; the four consumers delegate +/// here in later tasks (M3.2–M3.5). +/// +/// +/// The deny-list is intentionally the UNION of the two existing +/// implementations — it forbids System.Diagnostics.Process (not all of +/// System.Diagnostics, so Stopwatch stays allowed), all of +/// System.Net, all of System.Threading except Tasks / +/// CancellationToken(Source), plus System.Reflection, +/// System.Runtime.InteropServices, and Microsoft.Win32. +/// +/// +public static class ScriptTrustPolicy +{ + /// + /// Forbidden API roots. Each entry is matched as a prefix against the + /// resolved symbol's containing namespace and fully-qualified containing + /// type — an entry may name a whole namespace ("System.IO") or a single + /// type ("System.Diagnostics.Process"). + /// + public static readonly string[] ForbiddenScopes = + [ + "System.IO", + "System.Diagnostics.Process", + "System.Threading", + "System.Reflection", + "System.Net", + "System.Runtime.InteropServices", + "Microsoft.Win32", + ]; + + /// + /// Specific namespaces/types allowed even though they sit under a forbidden + /// root. async/await and cancellation tokens are OK despite + /// System.Threading being blocked. + /// + public static readonly string[] AllowedExceptions = + [ + "System.Threading.Tasks", + "System.Threading.CancellationToken", + "System.Threading.CancellationTokenSource", + ]; + + /// + /// Member names that are reflection gateways. Reaching any of these — even + /// off a permitted type such as typeof(string) — lets a script + /// escape the namespace deny-list (obtain an arbitrary Type, load an + /// assembly, late-bind a method). They are rejected regardless of the + /// receiver expression. + /// + public static readonly HashSet ReflectionGatewayMembers = new(StringComparer.Ordinal) + { + "GetType", + "GetTypeInfo", + "Assembly", + "Module", + "CreateInstance", + "InvokeMember", + "GetMethod", + "GetMethods", + "GetConstructor", + "GetConstructors", + "GetField", + "GetFields", + "GetProperty", + "GetProperties", + "GetMember", + "GetMembers", + "GetRuntimeMethod", + "GetRuntimeMethods", + "MethodHandle", + "TypeHandle", + }; + + /// + /// Bare identifiers that are forbidden outright. dynamic widens + /// late-bound member access the static walker cannot see through; + /// Activator has no non-reflection use. + /// + public static readonly HashSet ForbiddenIdentifiers = new(StringComparer.Ordinal) + { + "dynamic", + "Activator", + }; + + /// + /// Assemblies referenced by compiled scripts. Shared between the Roslyn + /// scripting options and the semantic-analysis compilation built for trust + /// validation, so the validator resolves symbols against exactly the same + /// metadata the script is compiled against. + /// + public static readonly IReadOnlyList DefaultAssemblies = + [ + typeof(object).Assembly, + typeof(System.Linq.Enumerable).Assembly, + typeof(System.Math).Assembly, + typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly, + typeof(DynamicJsonElement).Assembly, + ]; + + /// + /// Metadata references for the trust-validation semantic compilation and + /// the design-time script compilation. + /// + public static readonly IReadOnlyList DefaultReferences = + DefaultAssemblies + .Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location)) + .ToList(); + + /// + /// Default namespace imports made available to compiled scripts. + /// + public static readonly string[] DefaultImports = + [ + "System", + "System.Collections.Generic", + "System.Linq", + "System.Threading.Tasks", + ]; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs new file mode 100644 index 00000000..06f6ee62 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs @@ -0,0 +1,369 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +/// +/// M3.1: the single authoritative script-trust validator, fusing the two +/// previously-divergent implementations: +/// +/// +/// +/// Semantic symbol analysis (ported from the SiteRuntime +/// ScriptCompilationService.ValidateTrustModel): the script is parsed and +/// a semantic model built; every identifier / type reference / member access / +/// object creation is resolved to its symbol and the symbol's containing +/// namespace + type are checked against . +/// This catches forbidden types regardless of how they are written — +/// global:: prefixes, aliases, using static, transitively-imported +/// namespaces — because it inspects the resolved symbol, not the spelling. +/// +/// +/// Reflection-gateway + dynamic/Activator hardening (ported from the +/// InboundAPI ForbiddenApiChecker.ForbiddenApiWalker): a syntactic walk +/// that rejects any member access whose accessed member is in +/// (regardless of +/// receiver — so typeof(string).Assembly.GetType("System.IO.File") is +/// caught even though it never spells a forbidden namespace), any identifier in +/// (dynamic, +/// Activator), and forbidden using/qualified-name spellings. +/// +/// +/// +/// +/// Keeping BOTH passes is deliberate: the semantic pass catches alias / +/// using static / global:: escapes; the syntactic pass catches +/// reflection reached through members of permitted types. Neither pass +/// is a true sandbox — this is best-effort defence-in-depth; genuine containment +/// needs a runtime boundary. +/// +/// +public static class ScriptTrustValidator +{ + /// + /// Analyses the script source and returns the deduped, sorted list of + /// trust-model violations. An empty list means the script is clean. + /// + /// The C# script source to analyse. + /// + /// Optional additional metadata references to add to + /// for semantic + /// resolution — e.g. the compile-surface globals assembly so a script + /// referencing the API surface resolves cleanly. Forbidden references are + /// NOT added here (a script can't reach a forbidden API just because the + /// assembly is referenced; the deny-list still applies). + /// + /// A list of trust-model violation messages; empty if the script is clean. + public static IReadOnlyList FindViolations( + string code, + IEnumerable? extraReferences = null) + { + if (string.IsNullOrWhiteSpace(code)) + return Array.Empty(); + + var tree = CSharpSyntaxTree.ParseText( + code, new CSharpParseOptions(kind: SourceCodeKind.Script)); + var root = tree.GetRoot(); + + // Deduplicate so a forbidden symbol used many times is reported once but + // distinct forbidden symbols are all reported. + var violations = new SortedSet(StringComparer.Ordinal); + + // ---- Pass 1: semantic symbol analysis (ported from SiteRuntime) ---- + var references = ScriptTrustPolicy.DefaultReferences.ToList(); + if (extraReferences != null) + references.AddRange(extraReferences); + + var compilation = CSharpCompilation.CreateScriptCompilation( + "TrustValidation", + tree, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var model = compilation.GetSemanticModel(tree); + + foreach (var node in root.DescendantNodes()) + { + // Only inspect nodes that name a type or member; skip declarations, + // string literals and comments entirely. Member-access and + // qualified-name parents are evaluated as a whole, so their nested + // name parts are skipped. + if (node is not (SimpleNameSyntax or MemberAccessExpressionSyntax + or QualifiedNameSyntax or ObjectCreationExpressionSyntax)) + { + continue; + } + + var info = model.GetSymbolInfo(node); + var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault(); + + // The set of fully-qualified scopes this reference touches: the + // resolved symbol's containing namespace and type, or — when the + // symbol could not be resolved (a type from an unreferenced + // assembly) — the syntactic fully-qualified name written in source + // as a safe fallback. + var scopes = symbol != null + ? GetSymbolScopes(symbol) + : GetSyntacticScopes(node); + if (scopes.Count == 0) + continue; + + var forbidden = ScriptTrustPolicy.ForbiddenScopes.FirstOrDefault( + f => scopes.Any(s => IsUnderScope(s, f))); + if (forbidden == null) + continue; + + // Allow specific exception namespaces/types (async/await, cancellation). + if (scopes.Any(s => ScriptTrustPolicy.AllowedExceptions.Any(a => IsUnderScope(s, a)))) + continue; + + var name = symbol?.Name ?? node.ToString(); + violations.Add($"Forbidden API reference: '{forbidden}' ({scopes[0]}.{name})"); + } + + // ---- Pass 2: reflection-gateway + dynamic/Activator hardening + // (ported from InboundAPI) ---- + var walker = new HardeningWalker(violations); + walker.Visit(root); + + return violations.ToList(); + } + + /// + /// Returns the fully-qualified scopes a resolved symbol belongs to — its + /// containing namespace and, for a type or member, the fully-qualified + /// containing type. A bare namespace symbol is intentionally ignored: a + /// namespace name on its own performs no action; harm requires referencing + /// a type or a member. + /// + private static List GetSymbolScopes(ISymbol symbol) + { + var scopes = new List(); + + switch (symbol) + { + case INamespaceSymbol: + // A namespace reference alone is harmless — skip it. (This + // avoids a false positive on the "System.Threading" qualifier + // of the allowed "System.Threading.Tasks.Task".) + break; + case ITypeSymbol typeSymbol: + scopes.Add(typeSymbol.ToDisplayString()); + if (typeSymbol.ContainingNamespace is { IsGlobalNamespace: false } typeNs) + scopes.Add(typeNs.ToDisplayString()); + break; + default: + if (symbol.ContainingType != null) + { + scopes.Add(symbol.ContainingType.ToDisplayString()); + if (symbol.ContainingType.ContainingNamespace is { IsGlobalNamespace: false } memberNs) + scopes.Add(memberNs.ToDisplayString()); + } + else if (symbol.ContainingNamespace is { IsGlobalNamespace: false } ns) + { + scopes.Add(ns.ToDisplayString()); + } + + break; + } + + return scopes; + } + + /// + /// Fallback used when a name could not be resolved to a symbol (e.g. a type + /// from an assembly the script is not allowed to reference). The + /// fully-qualified name as written in source is used directly — a script + /// that names System.Net.Http.HttpClient is still rejected even + /// though that assembly is deliberately absent from the script's metadata + /// references. + /// + private static List GetSyntacticScopes(SyntaxNode node) + { + // A dotted name written in source is itself the fully-qualified scope. + // Only consider names that actually contain a dot — bare local + // identifiers cannot reach a forbidden namespace. + var text = node switch + { + QualifiedNameSyntax q => q.ToString(), + MemberAccessExpressionSyntax m => m.ToString(), + _ => string.Empty, + }; + + // Strip whitespace/newlines that a multi-line member-access chain may contain. + text = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray()); + + return string.IsNullOrEmpty(text) || !text.Contains('.') + ? [] + : [text]; + } + + /// + /// True if is exactly, or nested within, + /// (e.g. "System.IO.Compression" is under + /// "System.IO", "System.Diagnostics.Process" is under + /// "System.Diagnostics.Process"). + /// + private static bool IsUnderScope(string actual, string root) + => actual.Equals(root, StringComparison.Ordinal) + || actual.StartsWith(root + ".", StringComparison.Ordinal); + + /// + /// True if a dotted name (a using import or qualified name as written) + /// is forbidden — under a forbidden scope and not under an allowed exception. + /// + private static bool IsForbiddenDottedName(string dottedName) + { + foreach (var allowed in ScriptTrustPolicy.AllowedExceptions) + { + if (IsUnderScope(dottedName, allowed)) + return false; + } + + foreach (var forbidden in ScriptTrustPolicy.ForbiddenScopes) + { + if (IsUnderScope(dottedName, forbidden)) + return true; + } + + return false; + } + + /// + /// True if a dotted name is exactly an allowed-exception scope OR a strict + /// prefix of one — e.g. System.Threading is a prefix of the allowed + /// System.Threading.Tasks. Used by the hardening walker to stop + /// descending into the namespace qualifier of an allowed type so the bare + /// System.Threading qualifier of System.Threading.Tasks.Task + /// is not falsely flagged (the semantic pass already ignores bare + /// namespaces; this keeps the syntactic pass consistent). + /// + private static bool IsAllowedExceptionPrefix(string dottedName) + { + foreach (var allowed in ScriptTrustPolicy.AllowedExceptions) + { + if (IsUnderScope(dottedName, allowed) + || allowed.StartsWith(dottedName + ".", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// Syntactic walker porting the InboundAPI ForbiddenApiWalker + /// hardening rules — reflection-gateway members, the dynamic keyword, + /// Activator, and forbidden using/qualified-name spellings. + /// Writes into the same dedup set as the semantic pass. + /// + private sealed class HardeningWalker : CSharpSyntaxWalker + { + private readonly SortedSet _violations; + + internal HardeningWalker(SortedSet violations) => _violations = violations; + + /// + /// Strips whitespace/newlines a multi-line member-access chain may carry, + /// so prefix matching against the dotted policy scopes is reliable. + /// + private static string StripWhitespace(string text) + => new(text.Where(c => !char.IsWhiteSpace(c)).ToArray()); + + /// + public override void VisitUsingDirective(UsingDirectiveSyntax node) + { + if (node.Name is not null && IsForbiddenDottedName(node.Name.ToString())) + _violations.Add($"forbidden namespace import '{node.Name}'"); + + base.VisitUsingDirective(node); + } + + /// + public override void VisitQualifiedName(QualifiedNameSyntax node) + { + var text = StripWhitespace(node.ToString()); + + // An allowed-exception name (or a strict prefix of one, e.g. + // "System.Threading" under "System.Threading.Tasks") is OK — stop + // descending so the bare forbidden-namespace qualifier of an allowed + // type is not re-examined and falsely flagged. + if (IsAllowedExceptionPrefix(text)) + return; + + // Check the longest qualified name; do not descend so a single + // System.IO.File reference is reported once, not three times. + if (IsForbiddenDottedName(text)) + { + _violations.Add($"forbidden type reference '{text}'"); + return; + } + + base.VisitQualifiedName(node); + } + + /// + public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node) + { + var text = StripWhitespace(node.ToString()); + + // Reject reflection-gateway members regardless of the receiver — run + // this FIRST, before any allowed-exception early-return, so a + // gateway member hung off an allowed type + // (e.g. System.Threading.Tasks.Task.GetType()) is still caught. + // typeof(string).Assembly.GetType("System.IO.File") never spells a + // forbidden namespace, but '.Assembly' and '.GetType' appear here as + // the accessed member name. + var memberName = node.Name.Identifier.ValueText; + if (ScriptTrustPolicy.ReflectionGatewayMembers.Contains(memberName)) + { + _violations.Add($"forbidden reflection member access '.{memberName}'"); + // Still descend: the receiver may contain a further violation. + } + + // An allowed-exception member-access (or a strict prefix of one) is + // OK — stop descending so the bare forbidden-namespace qualifier of + // an allowed type (e.g. the "System.Threading" inside + // "System.Threading.Tasks.Task.Delay") is not re-examined and + // falsely flagged. + if (IsAllowedExceptionPrefix(text)) + return; + + // Catches fully-qualified expressions such as System.IO.File.Delete(...). + if (IsForbiddenDottedName(text)) + { + _violations.Add($"forbidden API access '{text}'"); + return; + } + + base.VisitMemberAccessExpression(node); + } + + /// + public override void VisitIdentifierName(IdentifierNameSyntax node) + { + var text = node.Identifier.ValueText; + + // 'dynamic' widens late-bound member access the static walker cannot + // see through — reject its use outright. The 'dynamic' contextual + // keyword surfaces as an identifier name. + if (text == "dynamic") + { + _violations.Add("forbidden use of the 'dynamic' keyword"); + return; + } + + // A bare reference to a reflection entry-point type. 'Activator' has + // no non-reflection use. + if (ScriptTrustPolicy.ForbiddenIdentifiers.Contains(text) && text != "dynamic") + { + _violations.Add($"forbidden reflection type reference '{text}'"); + return; + } + + base.VisitIdentifierName(node); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/TriggerCompileSurface.cs b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/TriggerCompileSurface.cs new file mode 100644 index 00000000..00e8b17c --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/TriggerCompileSurface.cs @@ -0,0 +1,51 @@ +namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +/// +/// M3.1: a compile-only mirror of the SiteRuntime +/// TriggerExpressionGlobals bind surface. A trigger expression is a bare +/// boolean expression referencing Attributes["x"] / Children["c"] +/// / Parent; the design-time deploy gate (M3.5) compiles candidate +/// trigger expressions against this type to catch undefined symbols. +/// +/// +/// Only the read-only bind surface is reproduced — the runtime +/// ExtractExpression helper and the snapshot-backed constructor are +/// intentionally omitted; they are runtime concerns, not part of what an +/// expression binds against. Member bodies are compile-only and throw +/// . +/// +/// +public sealed class TriggerCompileSurface +{ + private const string CompileOnly = "compile-only surface"; + + /// Mirrors TriggerExpressionGlobals.Attributes. + public ReadOnlyAttributes Attributes => throw new NotSupportedException(CompileOnly); + + /// Mirrors TriggerExpressionGlobals.Children. + public ReadOnlyChildren Children => throw new NotSupportedException(CompileOnly); + + /// Mirrors TriggerExpressionGlobals.Parent. + public ReadOnlyComposition? Parent => throw new NotSupportedException(CompileOnly); + + /// Compile-only mirror of TriggerExpressionGlobals.ReadOnlyAttributes. + public sealed class ReadOnlyAttributes + { + /// Mirrors ReadOnlyAttributes.this[string]. + public object? this[string key] => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of TriggerExpressionGlobals.ReadOnlyComposition. + public sealed class ReadOnlyComposition + { + /// Mirrors ReadOnlyComposition.Attributes. + public ReadOnlyAttributes Attributes => throw new NotSupportedException(CompileOnly); + } + + /// Compile-only mirror of TriggerExpressionGlobals.ReadOnlyChildren. + public sealed class ReadOnlyChildren + { + /// Mirrors ReadOnlyChildren.this[string]. + public ReadOnlyComposition this[string compositionName] => throw new NotSupportedException(CompileOnly); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj new file mode 100644 index 00000000..10236627 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs new file mode 100644 index 00000000..e6545685 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/RoslynScriptCompilerTests.cs @@ -0,0 +1,72 @@ +using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; + +namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests; + +/// +/// M3.1: parse + compile gate tests. The "representative real script" corpus is +/// the PRIMARY guard that faithfully mirrors +/// the runtime ScriptGlobals surface — if a member or signature drifts, +/// the corpus stops binding and this test fails. +/// +public class RoslynScriptCompilerTests +{ + [Fact] + public void ParseDiagnostics_NonEmpty_ForSyntaxError() + { + Assert.NotEmpty(RoslynScriptCompiler.ParseDiagnostics("var x = ;")); + } + + [Fact] + public void ParseDiagnostics_Empty_ForValidSyntax() + { + Assert.Empty(RoslynScriptCompiler.ParseDiagnostics("var x = 1;")); + } + + [Fact] + public void Compile_NonEmpty_ForUndefinedSymbol() + { + var code = "var x = NoSuchThing.Foo();"; + Assert.NotEmpty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface))); + } + + [Fact] + public void Compile_Empty_ForRepresentativeRealScript() + { + const string code = """ + var temp = Attributes["Temperature"]; + Attributes["Setpoint"] = 42; + var r = await ExternalSystem.Call("erp", "sync"); + var op = await Database.CachedWrite("hist", "INSERT ..."); + await Notify.To("ops").Send("subj", "msg"); + var shared = await Scripts.CallShared("Helper"); + var child = Children["Pump"].Attributes["Speed"]; + + // Widen coverage across the rest of the surface. + var attr = await Instance.GetAttribute("Temperature"); + await Instance.SetAttribute("Setpoint", "43"); + var track = await Instance.Tracking.Status(op); + var parentSpeed = Parent?.Attributes["Speed"]; + var alarmName = Alarm?.Name; + var p = Parameters; + var ct = CancellationToken; + var status = await Notify.Status("notif-id"); + var cachedCall = await ExternalSystem.CachedCall("erp", "ping"); + var resolved = Attributes.Resolve("Temperature"); + var conn = await Database.Connection("hist"); + var scope = Scope; + """; + + var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)); + Assert.Empty(diagnostics); + } + + [Fact] + public void Compile_Empty_ForTriggerExpression() + { + const string expr = + "Attributes[\"Temp\"] != null && (int)(Children[\"P\"].Attributes[\"S\"] ?? 0) > 5"; + + var diagnostics = RoslynScriptCompiler.Compile(expr, typeof(TriggerCompileSurface)); + Assert.Empty(diagnostics); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs new file mode 100644 index 00000000..ab7bee6c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs @@ -0,0 +1,132 @@ +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)); + } + + // ---- 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)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj new file mode 100644 index 00000000..49d43326 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + true + false + + + + + + + + + + + + + + + + + +