using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis; /// /// 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. /// /// /// /// A forbidden type reference inside nameof(...) (e.g. /// nameof(System.IO.File)) is deliberately flagged: this is /// conservative/fail-safe — nameof is rare in scripts and a script has /// no business naming a forbidden type even there, so we prefer fail-safe over /// adding special-case suppression logic to a security trust 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 widen symbol resolution /// for the semantic pass — e.g. the compile-surface globals assembly so a /// script referencing the API surface resolves cleanly. Extra references can /// ONLY improve resolution; they can NEVER whitelist a forbidden API. The /// verdict is by resolved namespace/type against /// , so passing more /// references — even the forbidden-API assemblies themselves — can only make /// the verdict more accurate (more types resolve to their true namespace and /// are judged), never produce a false allow. Callers therefore need not (and /// cannot, for safety purposes) curate this set; the Central UI run gate /// forwards its full compilation reference surface here precisely because /// doing so is safe. /// /// A list of trust-model violation messages; empty if the script is clean. public static IReadOnlyList FindViolations( string code, IEnumerable? extraReferences = null) => FindViolations(code, ScriptTrustPolicy.AnalysisReferences, extraReferences); /// /// Overload that runs the trust analysis against an explicit base reference /// set instead of . The /// public entry point above always uses the full analysis set; this overload /// exists so tests can pin behaviour against the minimal TPA-fallback set /// () and prove /// the degraded mode still catches the documented forbidden anchors. /// The two passes are otherwise identical. /// /// The C# script source to analyse. /// The base reference set Pass 1 resolves against. /// Optional additional references that only widen resolution. /// A list of trust-model violation messages; empty if the script is clean. public static IReadOnlyList FindViolations( string code, IReadOnlyList baseReferences, 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) ---- // Resolve against the supplied baseReferences so EVERY type a script names // resolves and is judged by its true namespace — closing the // forbidden-type-in-allowed-namespace blind spot (e.g. a bare // System.Diagnostics.Process via `using System.Diagnostics;`). The public // entry point passes the full trusted-platform reference set; a caller on the // degraded/test path may instead pass the minimal anchor-enriched fallback // (BuildMinimalFallbackReferences()). var references = baseReferences.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") must NOT be // reported — but we STILL DESCEND into its children so a forbidden // type nested inside (e.g. a generic argument under an allowed // System.Threading.Tasks.TaskCompletionSource) // is independently visited and flagged by this pass. Suppress the // outer report, do not stop the walk. (Pass 2 is self-sufficient and // does not rely on the semantic pass to catch nested forbidden refs.) if (IsAllowedExceptionPrefix(text)) { base.VisitQualifiedName(node); 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) // must NOT be reported — the bare forbidden-namespace qualifier of an // allowed type (e.g. the "System.Threading" inside // "System.Threading.Tasks.Task.Delay") is a false positive. But we // STILL DESCEND into its children so a forbidden reference nested // inside (e.g. a System.IO.File access in an allowed // System.Threading.Tasks.Task.Run(...) lambda body) is independently // visited and flagged by this pass. Suppress the outer report, do not // stop the walk. (Pass 2 is self-sufficient — it does not rely on the // semantic pass to catch nested forbidden refs.) if (IsAllowedExceptionPrefix(text)) { base.VisitMemberAccessExpression(node); 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. ('dynamic' already returned above.) if (ScriptTrustPolicy.ForbiddenIdentifiers.Contains(text)) { _violations.Add($"forbidden reflection type reference '{text}'"); return; } base.VisitIdentifierName(node); } } }