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);
}
}
}