From 784fee7b070e4f855b1c7b7c1de8df9dd9f58eb8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 19:35:43 -0400 Subject: [PATCH] refactor(inboundapi): M3.4 ForbiddenApiChecker delegates to shared ScriptAnalysis validator --- .../ForbiddenApiChecker.cs | 200 ++---------------- .../ZB.MOM.WW.ScadaBridge.InboundAPI.csproj | 1 + .../ForbiddenApiCheckerTests.cs | 56 ++++- 3 files changed, 70 insertions(+), 187 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ForbiddenApiChecker.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ForbiddenApiChecker.cs index 5d345b48..de112e1c 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ForbiddenApiChecker.cs +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ForbiddenApiChecker.cs @@ -1,6 +1,4 @@ -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; +using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; namespace ZB.MOM.WW.ScadaBridge.InboundAPI; @@ -8,89 +6,30 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI; /// InboundAPI-005: Enforces the ScadaBridge 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. +/// This class is now a thin shim that delegates to the shared, authoritative +/// implemented in +/// ZB.MOM.WW.ScadaBridge.ScriptAnalysis (M3.4). The unified validator runs +/// both a semantic symbol pass (catching alias / global:: / using static +/// escapes) and the reflection-gateway + dynamic / Activator syntactic +/// hardening that previously lived exclusively in this file. /// /// /// 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). +/// typeof(string).Assembly.GetType("System.IO.File"). The shared validator +/// handles this with both semantic resolution and reflection-gateway member +/// hardening — GetType, Assembly, GetMethod, InvokeMember, +/// CreateInstance, and the dynamic keyword are all rejected. This +/// remains hardening of a best-effort static check, not a true sandbox +/// (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. @@ -102,111 +41,6 @@ public static class ForbiddenApiChecker 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(); - - /// Gets the accumulated list of trust-model violation descriptions. - 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); - } + return ScriptTrustValidator.FindViolations(scriptCode); } } diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj index b6307013..665799b5 100644 --- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/ZB.MOM.WW.ScadaBridge.InboundAPI.csproj @@ -17,6 +17,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs index aa9fb536..7cebaf3d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/ForbiddenApiCheckerTests.cs @@ -3,9 +3,18 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests; /// /// InboundAPI-005 / InboundAPI-015: tests for the script-trust-model checker. /// -/// InboundAPI-015 hardens the textual walker against reflection reached through -/// permitted-type members that never spell a forbidden namespace, e.g. -/// typeof(string).Assembly.GetType("System.IO.File"). +/// Since M3.4 delegates to the shared +/// ScriptTrustValidator in ZB.MOM.WW.ScadaBridge.ScriptAnalysis. +/// The unified policy differs from the old local deny-list in one notable way: +/// System.Diagnostics is loosened to System.Diagnostics.Process +/// only — Stopwatch, Debug, and other non-Process Diagnostics +/// types are now ALLOWED. +/// +/// InboundAPI-015 hardening (reflection-gateway / dynamic / Activator) is +/// preserved unchanged via the shared validator. +/// +/// Violation message wording is intentionally not asserted — callers should +/// use .Count / .Any() rather than exact strings. /// public class ForbiddenApiCheckerTests { @@ -26,11 +35,37 @@ public class ForbiddenApiCheckerTests Assert.False(IsRejected(script), script); } + // --- System.Diagnostics loosening: Process is still forbidden; non-Process types + // (Stopwatch, Debug) are now ALLOWED by the unified policy (M3.4). The old + // InboundAPI checker blocked the entire System.Diagnostics namespace. --- + + [Fact] + public void Diagnostics_Process_StillForbidden() + { + // System.Diagnostics.Process is an explicit forbidden scope in ScriptTrustPolicy. + Assert.True(IsRejected("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")); + } + + [Fact] + public void Diagnostics_Stopwatch_NowAllowed() + { + // System.Diagnostics.Stopwatch is NOT under the forbidden scope + // "System.Diagnostics.Process" — the unified policy only forbids + // System.Diagnostics.Process, not the whole System.Diagnostics namespace. + Assert.False(IsRejected("var sw = new System.Diagnostics.Stopwatch(); sw.Start(); return null;")); + } + + [Fact] + public void Diagnostics_Debug_NowAllowed() + { + // System.Diagnostics.Debug is also not covered by the Process-only scope. + Assert.False(IsRejected("System.Diagnostics.Debug.WriteLine(\"hi\"); return null;")); + } + // --- Baseline: forbidden namespaces (textual) must still be rejected --- [Theory] [InlineData("System.IO.File.Delete(\"/tmp/x\"); return null;")] - [InlineData("System.Diagnostics.Process.Start(\"/bin/sh\"); return null;")] [InlineData("using System.Reflection; return null;")] [InlineData("var s = new System.Net.Sockets.Socket(default, default, default); return null;")] public void ForbiddenNamespace_Rejected(string script) @@ -101,4 +136,17 @@ public class ForbiddenApiCheckerTests // dynamic widens late-bound member access the static walker cannot see through. Assert.True(IsRejected("dynamic d = Parameters; return null;")); } + + // --- FindViolations returns a non-null, non-throwing result for edge inputs --- + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null!)] + public void EmptyOrWhitespace_ReturnsEmptyList(string? script) + { + var result = ForbiddenApiChecker.FindViolations(script!); + Assert.NotNull(result); + Assert.Empty(result); + } }