using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; namespace ScadaLink.InboundAPI; /// /// InboundAPI-005: Enforces the ScadaLink script trust model on inbound API method /// scripts before they are compiled into executable handlers. /// /// The trust model (CLAUDE.md, Akka.NET conventions) forbids scripts from reaching /// System.IO, System.Diagnostics.Process, System.Threading, /// System.Reflection, and raw network APIs. Roslyn scripting performs no /// API allow/deny-listing — restricting default imports is a convenience, not a /// sandbox — so a script can fully-qualify any referenced type. This static check /// walks the script syntax tree and rejects any reference to a forbidden namespace, /// whether reached through a using directive or a fully-qualified name. /// /// /// InboundAPI-015: a purely namespace-textual deny-list is bypassable because /// reflection is reachable through members of permitted types that never /// spell a forbidden namespace, e.g. /// typeof(string).Assembly.GetType("System.IO.File"). The walker therefore /// also rejects a curated set of reflection-gateway member names (GetType, /// Assembly, GetMethod, InvokeMember, CreateInstance, …) /// and the dynamic keyword. This is hardening of a best-effort static check, /// not a true sandbox — a determined script author may still find /// a vector the syntax walker cannot see (see the security notes in /// code-reviews/InboundAPI/findings.md, InboundAPI-015). The check is /// defence-in-depth; genuine containment needs a runtime boundary (restricted /// AssemblyLoadContext / curated reference set / out-of-process sandbox). /// /// public static class ForbiddenApiChecker { /// /// Namespace prefixes the trust model forbids. A script segment matches if it is /// equal to one of these or is a child namespace of it. /// private static readonly string[] ForbiddenNamespaces = { "System.IO", "System.Diagnostics", // covers Process "System.Threading", // Task/Tasks is explicitly re-allowed below "System.Reflection", "System.Net", // raw network (Sockets, HttpClient, etc.) "System.Runtime.InteropServices", "Microsoft.Win32", }; /// /// Namespaces that would otherwise be caught by a forbidden prefix but are /// required for normal async script authoring and carry no host-access risk. /// private static readonly string[] AllowedExceptions = { "System.Threading.Tasks", }; /// /// InboundAPI-015: member names that are reflection gateways. Reaching any of /// these — even off a permitted type such as typeof(string) or a plain /// 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. Invoke is deliberately /// excluded because Action/Func delegate invocation is legitimate; /// the reflection MethodInfo.Invoke path is already cut off by rejecting /// the GetMethod/GetConstructor that produces the MethodInfo. /// private static readonly HashSet ForbiddenMemberNames = new(StringComparer.Ordinal) { "GetType", // object.GetType() / Type.GetType(string) — yields a System.Type "GetTypeInfo", // -> TypeInfo (reflection) "Assembly", // Type.Assembly — yields a System.Reflection.Assembly "Module", // Type.Module / MethodBase.Module "CreateInstance", // Activator.CreateInstance / Assembly.CreateInstance "InvokeMember", // Type.InvokeMember — late-bound dispatch "GetMethod", "GetMethods", "GetConstructor", "GetConstructors", "GetField", "GetFields", "GetProperty", "GetProperties", "GetMember", "GetMembers", "GetRuntimeMethod", "GetRuntimeMethods", "MethodHandle", // RuntimeMethodHandle escape "TypeHandle", }; /// /// Analyses the script source and returns the list of trust-model violations. /// An empty list means the script is acceptable. /// public static IReadOnlyList FindViolations(string scriptCode) { if (string.IsNullOrWhiteSpace(scriptCode)) return Array.Empty(); var tree = CSharpSyntaxTree.ParseText( scriptCode, new CSharpParseOptions(kind: SourceCodeKind.Script)); var walker = new ForbiddenApiWalker(); walker.Visit(tree.GetRoot()); return walker.Violations; } private static bool IsForbidden(string dottedName) { foreach (var allowed in AllowedExceptions) { if (dottedName == allowed || dottedName.StartsWith(allowed + ".", StringComparison.Ordinal)) return false; } foreach (var forbidden in ForbiddenNamespaces) { if (dottedName == forbidden || dottedName.StartsWith(forbidden + ".", StringComparison.Ordinal)) return true; } return false; } private sealed class ForbiddenApiWalker : CSharpSyntaxWalker { private readonly List _violations = new(); public IReadOnlyList Violations => _violations; public override void VisitUsingDirective(UsingDirectiveSyntax node) { if (node.Name is not null && IsForbidden(node.Name.ToString())) _violations.Add($"forbidden namespace import '{node.Name}'"); base.VisitUsingDirective(node); } public override void VisitQualifiedName(QualifiedNameSyntax node) { // Check the longest qualified name; do not descend so a single // System.IO.File reference is reported once, not three times. var text = node.ToString(); if (IsForbidden(text)) { _violations.Add($"forbidden type reference '{text}'"); return; } base.VisitQualifiedName(node); } public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node) { // Catches fully-qualified expressions such as System.IO.File.Delete(...). var text = node.ToString(); if (IsForbidden(text)) { _violations.Add($"forbidden API access '{text}'"); return; } // InboundAPI-015: reject reflection-gateway members regardless of the // receiver. typeof(string).Assembly.GetType("System.IO.File") never // spells a forbidden namespace, but '.Assembly' and '.GetType' do // appear here as the accessed member name. var memberName = node.Name.Identifier.ValueText; if (ForbiddenMemberNames.Contains(memberName)) { _violations.Add($"forbidden reflection member access '.{memberName}'"); // Still descend: the receiver may contain a further violation. } base.VisitMemberAccessExpression(node); } public override void VisitIdentifierName(IdentifierNameSyntax node) { // InboundAPI-015: 'dynamic' widens late-bound member access that the // static walker cannot see through — reject its use outright. The // 'dynamic' contextual keyword surfaces as an identifier name. if (node.Identifier.ValueText == "dynamic") { _violations.Add("forbidden use of the 'dynamic' keyword"); return; } // InboundAPI-015: a bare reference to the reflection entry-point types // (e.g. 'Activator', 'Type') as an identifier. 'Activator' has no // non-reflection use; flag it. ('Type' as an identifier is too broad // to flag here — the gateway members above already cut off its use.) if (node.Identifier.ValueText == "Activator") { _violations.Add("forbidden reflection type reference 'Activator'"); return; } base.VisitIdentifierName(node); } } }