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. /// 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", }; /// /// 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; } base.VisitMemberAccessExpression(node); } } }