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",
];
}