using System.IO; using System.Reflection; using Microsoft.CodeAnalysis; using ZB.MOM.WW.ScadaBridge.Commons.Types; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; /// /// 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. /// /// /// 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", // Whole type — no legitimate script use. Exposes process control // (Exit/FailFast), the host environment, and secrets via environment // variables (SCADABRIDGE_API_KEY). Callers needing a line break use "\n". "System.Environment", // Whole type — GC control (Collect, KeepAlive, memory pressure knobs) has // no legitimate script use. "System.GC", // Concrete ADO.NET provider namespaces. Scripts reach a DbConnection ONLY // via the Database helper, whose abstract System.Data.Common types stay // allowed; forbidding the concrete providers closes the arbitrary-host // `new SqlConnection("Server=attacker")` channel. Deliberately NOT // forbidding System.Data or System.Data.Common broadly — only these // concrete provider namespaces. "Microsoft.Data", "System.Data.SqlClient", "System.Data.Odbc", "System.Data.OleDb", ]; /// /// 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", "GetTypes", "EntryPoint", "DeclaredMethods", "DeclaredMembers", "DeclaredConstructors", "DynamicInvoke", // DELIBERATELY EXCLUDED: "Invoke". The syntactic pass rejects a gateway // member regardless of receiver, and delegate.Invoke() is a legitimate, // common script pattern (Func<>/Action<> invocation). MethodInfo.Invoke // (the reflection late-bind) is already caught semantically via the // System.Reflection scope, so excluding "Invoke" here avoids a // false-positive blast radius without weakening the reflection deny-list. }; /// /// 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. /// /// /// This is the minimal, runtime-fidelity set. It deliberately does /// NOT reference the assemblies that host the forbidden APIs (e.g. /// System.Diagnostics.Process.dll, System.Net.Sockets.dll) — a /// forbidden type must remain an undefined symbol at compile time so /// the gate independently rejects it. Do /// not widen this set with forbidden-API anchor assemblies; that second layer /// of defence (a forbidden type fails to bind at execution-time compilation) /// depends on it staying minimal. The trust validator's symbol-resolution /// needs are met separately by (which adds /// on the minimal-fallback path). /// /// 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, ]; /// /// Anchor assemblies that host the forbidden-API types named in /// . Used ONLY to enrich the trust /// validator's semantic reference set () /// when the TRUSTED_PLATFORM_ASSEMBLIES list is unavailable — a /// single-file, AOT, or trimmed host. Without these, the minimal fallback set /// cannot resolve a bare forbidden type that lives inside an /// allowed namespace (the documented case being Process via /// using System.Diagnostics;): the symbol resolves to nothing, Pass 1's /// syntactic fallback ignores dotless identifiers, and Pass 2 never flags a /// bare identifier — so the forbidden reference slips the validator entirely. /// Anchoring these assemblies in the fallback keeps the /// semantic pass authoritative even in the degraded mode. /// /// /// These are never added to — see the /// remark on . Most forbidden anchor types /// (System.IO.File, System.Threading.Thread, /// System.Reflection.Assembly, /// System.Runtime.InteropServices.Marshal) already live in the same /// assembly as typeof(object) (System.Private.CoreLib), so the /// minimal set already resolves them; the ones that ship in their own /// assemblies — System.Diagnostics.Process and /// System.Net.Sockets.Socket — are listed here explicitly. /// /// public static readonly IReadOnlyList ForbiddenAnchorAssemblies = [ typeof(System.Diagnostics.Process).Assembly, typeof(System.IO.File).Assembly, typeof(System.Threading.Thread).Assembly, typeof(System.Reflection.Assembly).Assembly, typeof(System.Net.Sockets.Socket).Assembly, typeof(System.Runtime.InteropServices.Marshal).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(); /// /// The full trusted-platform reference set used ONLY by /// 's semantic analysis — NOT by /// . Unlike /// (the minimal, runtime-fidelity set used to decide script validity, /// which must mirror exactly what the site runtime compiles against), the /// trust validator references the entire framework so that EVERY type a /// script names resolves to a real symbol and is judged by its true /// namespace. Without this, a forbidden TYPE that sits inside an ALLOWED /// namespace and is reached as a bare identifier — the only such case in the /// policy being System.Diagnostics.Process via /// using System.Diagnostics; — would not resolve against a minimal /// reference set and would slip past the semantic pass (still blocked /// downstream as an undefined-symbol compile error, but with a misleading /// message). Referencing the full framework lets the validator flag it /// authoritatively as a forbidden API. Enriching the analysis reference set /// can only IMPROVE detection — the verdict is by namespace/type, so more /// resolvable symbols means more correct verdicts, never a false allow. /// public static readonly IReadOnlyList AnalysisReferences = BuildAnalysisReferences(); /// /// True when was built WITHOUT the /// trusted-platform-assemblies (TPA) list — i.e. the semantic pass is running /// against the minimal fallback set (a single-file/AOT/trimmed host) rather /// than the full framework. In this degraded mode the validator still /// resolves the documented forbidden anchors (because /// is folded into the fallback), but /// types outside that anchor set may resolve as unknown and rely on the /// syntactic pass / downstream compile gate. A warning is also emitted via /// at type initialisation. Consumers /// (and tests) can read this flag to detect / surface the weakened mode. /// public static bool AnalysisReferencesDegraded { get; private set; } private static IReadOnlyList BuildAnalysisReferences() { var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); // Trusted platform assemblies = the full framework reference set the host // started with; lets the semantic pass resolve any BCL type. if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string tpa) { foreach (var path in tpa.Split(Path.PathSeparator)) { if (path.Length == 0 || !path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || byPath.ContainsKey(path) || !File.Exists(path)) { continue; } try { byPath[path] = MetadataReference.CreateFromFile(path); } catch { /* skip an unreadable assembly rather than fail validation */ } } } // The TPA list was unavailable (single-file / AOT / trimmed host) — we // are about to fall back to the minimal set, which weakens the semantic // pass. Make the degradation LOUD (not silent): record the flag and emit // a warning so operators/tests can detect the mode. var tpaAvailable = byPath.Count > 0; if (!tpaAvailable) { AnalysisReferencesDegraded = true; System.Diagnostics.Trace.TraceWarning( "ScriptTrustPolicy: TRUSTED_PLATFORM_ASSEMBLIES unavailable; the script-trust " + "semantic pass is running against the MINIMAL fallback reference set (plus the " + "forbidden-API anchor assemblies). Symbol resolution is reduced — forbidden anchors " + "(Process, Socket, File, Thread, Assembly, Marshal) remain caught, but other types " + "may resolve as unknown. This typically indicates a single-file/AOT/trimmed host."); } // Ensure app assemblies the script API surface needs are present even if // not in the TPA list (e.g. Commons / DynamicJsonElement). foreach (var asm in DefaultAssemblies) TryAddAssembly(byPath, asm); // On the minimal-fallback path, also fold in the assemblies that HOST the // forbidden-API types so a bare forbidden type inside an allowed namespace // (the documented `Process` via `using System.Diagnostics;` case) still // resolves and is flagged authoritatively by the semantic pass. When the // TPA list is present these are already covered, so this only matters in // the degraded mode. NOTE: these anchors are added // ONLY here, never to DefaultReferences — the compile gate must keep // rejecting forbidden types as undefined symbols. if (!tpaAvailable) { foreach (var asm in ForbiddenAnchorAssemblies) TryAddAssembly(byPath, asm); } // Should never be empty (DefaultAssemblies always resolve), but guard // against a pathological host and fall back to the minimal references. return byPath.Count > 0 ? byPath.Values.ToList() : DefaultReferences; } private static void TryAddAssembly( Dictionary byPath, Assembly asm) { var loc = asm.Location; if (loc.Length == 0 || byPath.ContainsKey(loc) || !File.Exists(loc)) return; try { byPath[loc] = MetadataReference.CreateFromFile(loc); } catch { /* skip an unreadable assembly rather than fail validation */ } } /// /// Builds exactly the reference set the trust validator would use on the /// minimal TPA-fallback path: plus the /// that host the forbidden-API types. /// This is what produces when /// TRUSTED_PLATFORM_ASSEMBLIES is unavailable. Exposed so the degraded /// mode can be exercised directly (e.g. by the adversarial tests proving the /// fallback hole is closed) without depending on the host actually /// lacking a TPA list. /// /// The minimal fallback reference set (default assemblies plus forbidden-API anchor assemblies). public static IReadOnlyList BuildMinimalFallbackReferences() { var byPath = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var asm in DefaultAssemblies) TryAddAssembly(byPath, asm); foreach (var asm in ForbiddenAnchorAssemblies) TryAddAssembly(byPath, asm); return byPath.Values.ToList(); } /// /// Default namespace imports made available to compiled scripts. /// public static readonly string[] DefaultImports = [ "System", "System.Collections.Generic", "System.Linq", "System.Threading.Tasks", ]; }