From a9bd7ee37cef883f6670d73ff16efe13491f7679 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 16 May 2026 18:41:12 -0400 Subject: [PATCH] =?UTF-8?q?fix(central-ui):=20resolve=20CentralUI-001=20?= =?UTF-8?q?=E2=80=94=20enforce=20script=20trust=20model=20before=20sandbox?= =?UTF-8?q?=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScriptAnalysisService.RunInSandboxAsync compiled and executed arbitrary user C# in the central host process with no trust-model enforcement — the forbidden-API set was only a Monaco editor diagnostic. A Design-role user could run System.IO/Process/Reflection/network code on the central node. Added a Roslyn semantic gate (EnforceTrustModel) invoked after compilation and before script.RunAsync, and on nested shared scripts in callSharedFunc; a script referencing any forbidden API is rejected before it runs. Reworked FindForbiddenApiUsages: it now resolves every identifier against the semantic model and checks types and members, so a fully-qualified call (System.IO.File.WriteAllText) is caught — the pre-fix check only inspected the leftmost identifier and missed that shape. This is a static semantic gate, not a process sandbox. Adds gate regression tests that fail against the pre-fix code, plus a clean-script test guarding against over-blocking. --- code-reviews/CentralUI/findings.md | 21 +++- code-reviews/README.md | 9 +- .../ScriptAnalysis/ScriptAnalysisService.cs | 119 +++++++++++++++--- .../ScriptAnalysisServiceTests.cs | 60 +++++++++ 4 files changed, 186 insertions(+), 23 deletions(-) diff --git a/code-reviews/CentralUI/findings.md b/code-reviews/CentralUI/findings.md index 9573755..444e226 100644 --- a/code-reviews/CentralUI/findings.md +++ b/code-reviews/CentralUI/findings.md @@ -8,7 +8,7 @@ | Last reviewed | 2026-05-16 | | Reviewer | claude-agent | | Commit reviewed | `9c60592` | -| Open findings | 19 | +| Open findings | 18 | ## Summary @@ -55,7 +55,7 @@ pages and the auth bridge are untested. |--|--| | Severity | Critical | | Category | Security | -| Status | Open | +| Status | Resolved | | Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:171-424` | **Description** @@ -85,7 +85,22 @@ an editor hint. **Resolution** -_Unresolved._ +Resolved 2026-05-16. A Roslyn semantic trust-model gate was added. `RunInSandboxAsync` +now calls `EnforceTrustModel` after compilation and before `script.RunAsync`; if the +script references any forbidden API the run is rejected (`SandboxErrorKind.CompileError`) +with the offending markers, and the same gate is applied to nested shared scripts in +`callSharedFunc`. `FindForbiddenApiUsages` was reworked so it resolves every identifier +(not just the leftmost) against the semantic model and checks types **and** members — +so a fully-qualified call such as `System.IO.File.WriteAllText(...)` is now caught, not +only `using`-directive or bare-type forms. This is a static semantic gate consistent +with the documented trust model; it is not a process sandbox — reflection-based +indirection remains out of its reach, and full isolation would require running scripts +in a separate constrained process (a larger change deliberately not taken here). +Regression tests `RunInSandbox_FullyQualifiedForbiddenApi_IsBlockedBeforeExecution`, +`RunInSandbox_ForbiddenUsingDirective_IsBlockedBeforeExecution` and +`Diagnose_FullyQualifiedForbiddenCall_RaisesSCADA002` fail against the pre-fix code and +pass after; `RunInSandbox_CleanScript_StillRuns` guards against over-blocking. Fixed by +the commit whose message references `CentralUI-001`. ### CentralUI-002 — Site-scoped Deployment permissions are issued but never enforced diff --git a/code-reviews/README.md b/code-reviews/README.md index fc57e07..aa23022 100644 --- a/code-reviews/README.md +++ b/code-reviews/README.md @@ -34,17 +34,17 @@ resolved and re-triaged. | Severity | Open findings | |----------|---------------| -| Critical | 4 | +| Critical | 3 | | High | 46 | | Medium | 100 | | Low | 89 | -| **Total** | **239** | +| **Total** | **238** | ## Module Status | Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total | |--------|---------------|--------|----------------|------|-------| -| [CentralUI](CentralUI/findings.md) | 2026-05-16 | `9c60592` | 1/3/10/5 | 19 | 19 | +| [CentralUI](CentralUI/findings.md) | 2026-05-16 | `9c60592` | 0/3/10/5 | 18 | 19 | | [CLI](CLI/findings.md) | 2026-05-16 | `9c60592` | 0/1/6/6 | 13 | 13 | | [ClusterInfrastructure](ClusterInfrastructure/findings.md) | 2026-05-16 | `9c60592` | 0/1/4/3 | 8 | 8 | | [Commons](Commons/findings.md) | 2026-05-16 | `9c60592` | 0/0/4/8 | 12 | 12 | @@ -71,11 +71,10 @@ Resolved findings drop off this list but remain recorded in their module's `findings.md` (see [REVIEW-PROCESS.md](REVIEW-PROCESS.md) §4–§5). Full detail — description, location, recommendation — lives in the module's `findings.md`. -### Critical (4) +### Critical (3) | ID | Module | Title | |----|--------|-------| -| CentralUI-001 | [CentralUI](CentralUI/findings.md) | Test Run sandbox executes arbitrary C# with no trust-model enforcement | | ExternalSystemGateway-001 | [ExternalSystemGateway](ExternalSystemGateway/findings.md) | No S&F delivery handler registered; cached calls and writes can never be delivered | | NotificationService-001 | [NotificationService](NotificationService/findings.md) | Buffered notifications are never retried (no S&F delivery handler) | | StoreAndForward-001 | [StoreAndForward](StoreAndForward/findings.md) | Replication to standby is never triggered by the active node | diff --git a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs index 28bdeac..4176c26 100644 --- a/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -220,6 +220,20 @@ public class ScriptAnalysisService SandboxErrorKind.CompileError, 0, markers); } + // Trust-model gate (CentralUI-001): the documented forbidden-API set is + // enforced HERE, before execution — not merely surfaced as an editor hint. + // Without this, a Design-role user could run arbitrary file/process/ + // reflection/network code in the central host process. + var trustViolations = EnforceTrustModel(script.GetCompilation()); + if (trustViolations.Count > 0) + { + return new SandboxRunResult(false, null, null, "", + "Script blocked by the trust model — it references forbidden APIs " + + "(System.IO, System.Diagnostics, System.Reflection, System.Net, threading). " + + "See the highlighted diagnostics.", + SandboxErrorKind.CompileError, 0, trustViolations); + } + var parameters = ConvertJsonParameters(request.Parameters); using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); @@ -311,6 +325,13 @@ public class ScriptAnalysisService throw new ScriptSandboxException( $"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}"); + // Trust-model gate (CentralUI-001) — a nested shared script runs + // arbitrary code too, so it must clear the same forbidden-API gate. + if (EnforceTrustModel(built.GetCompilation()).Count > 0) + throw new ScriptSandboxException( + $"Scripts.CallShared(\"{name}\") is blocked by the script trust model — " + + "the shared script references forbidden APIs."); + lock (compileCacheLock) { if (!compileCache.TryGetValue(name, out compiled)) @@ -1086,15 +1107,25 @@ public class ScriptAnalysisService return new(AttributeContextKind.None, null); } + /// + /// Finds every reference to a forbidden API — the documented script trust model, + /// see . Identifiers are resolved against + /// the semantic model, so a forbidden type or member is caught however it is + /// written: bare (File), fully qualified + /// (System.IO.File.WriteAllText), or via an alias — while a user identifier + /// that merely shares a name with a forbidden type (var File = …) does not + /// false-positive. Used both for editor diagnostics and as the pre-execution + /// trust-model gate (see ). + /// private static IEnumerable FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model) { var root = tree.GetRoot(); - // Banned using directives — pure namespace string match is fine here. + // Banned using directives. foreach (var u in root.DescendantNodes().OfType()) { var name = u.Name?.ToString() ?? ""; - if (ForbiddenNamespacePrefixes.Any(p => name == p || name.StartsWith(p + "."))) + if (IsForbiddenName(name)) { var span = u.GetLocation().GetLineSpan().Span; yield return new DiagnosticMarker( @@ -1108,20 +1139,14 @@ public class ScriptAnalysisService } } - // Banned type usages — resolved via the semantic model so a user - // identifier named "File" or "Thread" does NOT trigger the diagnostic - // unless it actually resolves to a forbidden type. + // Banned type / member references, resolved via the semantic model. Every + // identifier is checked — including the right-hand side of a member access — + // so a fully-qualified forbidden call (System.IO.File.WriteAllText) cannot + // slip past by avoiding a `using` directive or a bare type name. foreach (var ident in root.DescendantNodes().OfType()) { - // Skip the identifier on the right side of a member access — only - // the leftmost (the type or qualifier) is what we want to check. - if (ident.Parent is MemberAccessExpressionSyntax m && m.Name == ident) continue; - - var symbol = model.GetSymbolInfo(ident).Symbol; - if (symbol is not INamedTypeSymbol type) continue; - - var ns = type.ContainingNamespace?.ToDisplayString() ?? ""; - if (!ForbiddenNamespacePrefixes.Any(p => ns == p || ns.StartsWith(p + "."))) continue; + var forbidden = ForbiddenNameFor(model.GetSymbolInfo(ident).Symbol); + if (forbidden == null) continue; var span = ident.GetLocation().GetLineSpan().Span; yield return new DiagnosticMarker( @@ -1130,11 +1155,75 @@ public class ScriptAnalysisService StartColumn: span.Start.Character + 1, EndLineNumber: span.End.Line + 1, EndColumn: span.End.Character + 1, - Message: $"Type '{type.Name}' from forbidden namespace '{ns}' is not allowed in scripts.", + Message: $"'{ident.Identifier.ValueText}' resolves to forbidden API '{forbidden}', " + + "which is not allowed in scripts (script trust model).", Code: "SCADA002"); } } + /// + /// The forbidden namespace/type a symbol implicates, or null if it is allowed. + /// Checks the symbol's namespace and — for a type or member — the type's full + /// name, so an entry like System.Threading.Thread bans that exact type + /// while System.Threading (e.g. CancellationToken) stays allowed. + /// + private static string? ForbiddenNameFor(ISymbol? symbol) + { + if (symbol == null) return null; + foreach (var name in QualifiedNamesOf(symbol)) + if (IsForbiddenName(name)) + return name; + return null; + } + + /// Fully-qualified names a symbol reference implicates for trust-model checking. + private static IEnumerable QualifiedNamesOf(ISymbol symbol) + { + switch (symbol) + { + case INamespaceSymbol { IsGlobalNamespace: false } ns: + yield return ns.ToDisplayString(); + break; + case ITypeSymbol type: + if (type.ContainingNamespace is { IsGlobalNamespace: false } tn) + yield return tn.ToDisplayString(); + yield return FullTypeName(type); + break; + default: + if (symbol.ContainingType is { } ct) + { + if (ct.ContainingNamespace is { IsGlobalNamespace: false } cn) + yield return cn.ToDisplayString(); + yield return FullTypeName(ct); + } + break; + } + } + + private static string FullTypeName(ITypeSymbol type) => + type.ContainingNamespace is { IsGlobalNamespace: false } ns + ? ns.ToDisplayString() + "." + type.Name + : type.Name; + + private static bool IsForbiddenName(string qualifiedName) => + ForbiddenNamespacePrefixes.Any(p => + qualifiedName == p || qualifiedName.StartsWith(p + ".", StringComparison.Ordinal)); + + /// + /// Pre-execution trust-model gate (CentralUI-001). Returns the forbidden-API + /// markers (SCADA001/SCADA002) for a compiled script; an empty list means the + /// script is clear to run. This is a static semantic gate, not a process + /// sandbox — reflection-based indirection is still out of its reach; full + /// isolation would require running scripts in a separate constrained process. + /// + private static IReadOnlyList EnforceTrustModel(Compilation compilation) + { + var tree = compilation.SyntaxTrees.FirstOrDefault(); + if (tree == null) return Array.Empty(); + var model = compilation.GetSemanticModel(tree); + return FindForbiddenApiUsages(tree, model).ToList(); + } + private static CompletionItem ToCompletionItem(ISymbol symbol) { var kind = symbol.Kind switch diff --git a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs index 83ad758..3f4f51f 100644 --- a/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs +++ b/tests/ScadaLink.CentralUI.Tests/ScriptAnalysis/ScriptAnalysisServiceTests.cs @@ -405,4 +405,64 @@ public class ScriptAnalysisServiceTests Assert.Contains("name", resp.Markdown); Assert.Contains("String", resp.Markdown); } + + // ── CentralUI-001: trust-model gate before sandbox execution ────────── + + [Fact] + public void Diagnose_FullyQualifiedForbiddenCall_RaisesSCADA002() + { + // A forbidden API reached by fully-qualified name (no `using`, no bare + // type identifier) must still be flagged — the pre-fix semantic check + // only inspected the leftmost identifier and missed this shape. + var resp = _svc.Diagnose(new DiagnoseRequest( + "var d = System.IO.Directory.GetCurrentDirectory(); return d;")); + Assert.Contains(resp.Markers, m => m.Code == "SCADA002"); + } + + [Fact] + public async Task RunInSandbox_FullyQualifiedForbiddenApi_IsBlockedBeforeExecution() + { + // Regression test for CentralUI-001. RunInSandboxAsync used to execute any + // script that compiled, with no trust-model enforcement — so fully-qualified + // forbidden API code ran in the central host process. The fix gates execution + // on the forbidden-API analysis. + var result = await _svc.RunInSandboxAsync( + new SandboxRunRequest( + "var d = System.IO.Directory.GetCurrentDirectory(); return d;", + Parameters: null, + TimeoutSeconds: null), + CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind); + Assert.Contains("trust model", result.Error); + Assert.NotNull(result.Markers); + Assert.Contains(result.Markers!, m => m.Code is "SCADA001" or "SCADA002"); + } + + [Fact] + public async Task RunInSandbox_ForbiddenUsingDirective_IsBlockedBeforeExecution() + { + var result = await _svc.RunInSandboxAsync( + new SandboxRunRequest( + "using System.Diagnostics; var p = Process.GetCurrentProcess().Id; return p;", + Parameters: null, + TimeoutSeconds: null), + CancellationToken.None); + + Assert.False(result.Success); + Assert.Equal(SandboxErrorKind.CompileError, result.ErrorKind); + } + + [Fact] + public async Task RunInSandbox_CleanScript_StillRuns() + { + // The gate must not block a script that stays within the allowed surface. + var result = await _svc.RunInSandboxAsync( + new SandboxRunRequest("return 21 * 2;", Parameters: null, TimeoutSeconds: null), + CancellationToken.None); + + Assert.True(result.Success); + Assert.Equal("42", result.ReturnValueJson); + } }