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:
@@ -8,7 +8,7 @@
|
|||||||
| Last reviewed | 2026-05-16 |
|
| Last reviewed | 2026-05-16 |
|
||||||
| Reviewer | claude-agent |
|
| Reviewer | claude-agent |
|
||||||
| Commit reviewed | `9c60592` |
|
| Commit reviewed | `9c60592` |
|
||||||
| Open findings | 19 |
|
| Open findings | 18 |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ pages and the auth bridge are untested.
|
|||||||
|--|--|
|
|--|--|
|
||||||
| Severity | Critical |
|
| Severity | Critical |
|
||||||
| Category | Security |
|
| Category | Security |
|
||||||
| Status | Open |
|
| Status | Resolved |
|
||||||
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:171-424` |
|
| Location | `src/ScadaLink.CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:171-424` |
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
@@ -85,7 +85,22 @@ an editor hint.
|
|||||||
|
|
||||||
**Resolution**
|
**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
|
### CentralUI-002 — Site-scoped Deployment permissions are issued but never enforced
|
||||||
|
|
||||||
|
|||||||
@@ -34,17 +34,17 @@ resolved and re-triaged.
|
|||||||
|
|
||||||
| Severity | Open findings |
|
| Severity | Open findings |
|
||||||
|----------|---------------|
|
|----------|---------------|
|
||||||
| Critical | 4 |
|
| Critical | 3 |
|
||||||
| High | 46 |
|
| High | 46 |
|
||||||
| Medium | 100 |
|
| Medium | 100 |
|
||||||
| Low | 89 |
|
| Low | 89 |
|
||||||
| **Total** | **239** |
|
| **Total** | **238** |
|
||||||
|
|
||||||
## Module Status
|
## Module Status
|
||||||
|
|
||||||
| Module | Last reviewed | Commit | Open (C/H/M/L) | Open | Total |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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 —
|
`findings.md` (see [REVIEW-PROCESS.md](REVIEW-PROCESS.md) §4–§5). Full detail —
|
||||||
description, location, recommendation — lives in the module's `findings.md`.
|
description, location, recommendation — lives in the module's `findings.md`.
|
||||||
|
|
||||||
### Critical (4)
|
### Critical (3)
|
||||||
|
|
||||||
| ID | Module | Title |
|
| 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 |
|
| 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) |
|
| 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 |
|
| StoreAndForward-001 | [StoreAndForward](StoreAndForward/findings.md) | Replication to standby is never triggered by the active node |
|
||||||
|
|||||||
@@ -220,6 +220,20 @@ public class ScriptAnalysisService
|
|||||||
SandboxErrorKind.CompileError, 0, markers);
|
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);
|
var parameters = ConvertJsonParameters(request.Parameters);
|
||||||
|
|
||||||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||||
@@ -311,6 +325,13 @@ public class ScriptAnalysisService
|
|||||||
throw new ScriptSandboxException(
|
throw new ScriptSandboxException(
|
||||||
$"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}");
|
$"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)
|
lock (compileCacheLock)
|
||||||
{
|
{
|
||||||
if (!compileCache.TryGetValue(name, out compiled))
|
if (!compileCache.TryGetValue(name, out compiled))
|
||||||
@@ -1086,15 +1107,25 @@ public class ScriptAnalysisService
|
|||||||
return new(AttributeContextKind.None, null);
|
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)
|
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
|
||||||
{
|
{
|
||||||
var root = tree.GetRoot();
|
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>())
|
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
|
||||||
{
|
{
|
||||||
var name = u.Name?.ToString() ?? "";
|
var name = u.Name?.ToString() ?? "";
|
||||||
if (ForbiddenNamespacePrefixes.Any(p => name == p || name.StartsWith(p + ".")))
|
if (IsForbiddenName(name))
|
||||||
{
|
{
|
||||||
var span = u.GetLocation().GetLineSpan().Span;
|
var span = u.GetLocation().GetLineSpan().Span;
|
||||||
yield return new DiagnosticMarker(
|
yield return new DiagnosticMarker(
|
||||||
@@ -1108,20 +1139,14 @@ public class ScriptAnalysisService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Banned type usages — resolved via the semantic model so a user
|
// Banned type / member references, resolved via the semantic model. Every
|
||||||
// identifier named "File" or "Thread" does NOT trigger the diagnostic
|
// identifier is checked — including the right-hand side of a member access —
|
||||||
// unless it actually resolves to a forbidden type.
|
// 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>())
|
foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
|
||||||
{
|
{
|
||||||
// Skip the identifier on the right side of a member access — only
|
var forbidden = ForbiddenNameFor(model.GetSymbolInfo(ident).Symbol);
|
||||||
// the leftmost (the type or qualifier) is what we want to check.
|
if (forbidden == null) continue;
|
||||||
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 span = ident.GetLocation().GetLineSpan().Span;
|
var span = ident.GetLocation().GetLineSpan().Span;
|
||||||
yield return new DiagnosticMarker(
|
yield return new DiagnosticMarker(
|
||||||
@@ -1130,11 +1155,75 @@ public class ScriptAnalysisService
|
|||||||
StartColumn: span.Start.Character + 1,
|
StartColumn: span.Start.Character + 1,
|
||||||
EndLineNumber: span.End.Line + 1,
|
EndLineNumber: span.End.Line + 1,
|
||||||
EndColumn: span.End.Character + 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");
|
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)
|
private static CompletionItem ToCompletionItem(ISymbol symbol)
|
||||||
{
|
{
|
||||||
var kind = symbol.Kind switch
|
var kind = symbol.Kind switch
|
||||||
|
|||||||
@@ -405,4 +405,64 @@ public class ScriptAnalysisServiceTests
|
|||||||
Assert.Contains("name", resp.Markdown);
|
Assert.Contains("name", resp.Markdown);
|
||||||
Assert.Contains("String", 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user