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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user