fix(central-ui): resolve CentralUI-001 — enforce script trust model before sandbox execution

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.
This commit is contained in:
Joseph Doherty
2026-05-16 18:41:12 -04:00
parent a9ceba00d0
commit a9bd7ee37c
4 changed files with 186 additions and 23 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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);
}
/// <summary>
/// Finds every reference to a forbidden API — the documented script trust model,
/// see <see cref="ForbiddenNamespacePrefixes"/>. Identifiers are resolved against
/// the semantic model, so a forbidden type or member is caught however it is
/// written: bare (<c>File</c>), fully qualified
/// (<c>System.IO.File.WriteAllText</c>), or via an alias — while a user identifier
/// that merely shares a name with a forbidden type (<c>var File = …</c>) does not
/// false-positive. Used both for editor diagnostics and as the pre-execution
/// trust-model gate (see <see cref="EnforceTrustModel"/>).
/// </summary>
private static IEnumerable<DiagnosticMarker> 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<UsingDirectiveSyntax>())
{
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<IdentifierNameSyntax>())
{
// 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");
}
}
/// <summary>
/// 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 <c>System.Threading.Thread</c> bans that exact type
/// while <c>System.Threading</c> (e.g. <c>CancellationToken</c>) stays allowed.
/// </summary>
private static string? ForbiddenNameFor(ISymbol? symbol)
{
if (symbol == null) return null;
foreach (var name in QualifiedNamesOf(symbol))
if (IsForbiddenName(name))
return name;
return null;
}
/// <summary>Fully-qualified names a symbol reference implicates for trust-model checking.</summary>
private static IEnumerable<string> 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));
/// <summary>
/// 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.
/// </summary>
private static IReadOnlyList<DiagnosticMarker> EnforceTrustModel(Compilation compilation)
{
var tree = compilation.SyntaxTrees.FirstOrDefault();
if (tree == null) return Array.Empty<DiagnosticMarker>();
var model = compilation.GetSemanticModel(tree);
return FindForbiddenApiUsages(tree, model).ToList();
}
private static CompletionItem ToCompletionItem(ISymbol symbol)
{
var kind = symbol.Kind switch

View File

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