refactor(centralui): M3.5 ScriptAnalysisService uses shared deny-list + delegates trust verdict
This commit is contained in:
@@ -12,6 +12,7 @@ using Microsoft.CodeAnalysis.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using SharedTrust = ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
|
||||
@@ -56,18 +57,6 @@ public class ScriptAnalysisService
|
||||
"System.Text",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
// Namespaces and types banned by the script trust model.
|
||||
// Tasks live under System.Threading.Tasks and remain allowed.
|
||||
private static readonly string[] ForbiddenNamespacePrefixes =
|
||||
{
|
||||
"System.IO",
|
||||
"System.Diagnostics",
|
||||
"System.Reflection",
|
||||
"System.Net",
|
||||
"System.Threading.Thread",
|
||||
"System.Threading.Tasks.Sources",
|
||||
};
|
||||
|
||||
private readonly ISharedScriptCatalog _sharedScripts;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IServiceProvider _services;
|
||||
@@ -1152,14 +1141,22 @@ public class ScriptAnalysisService
|
||||
}
|
||||
|
||||
/// <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"/>).
|
||||
/// Finds every reference to a forbidden API for the editor squiggle — the
|
||||
/// documented script trust model, sourced from the shared
|
||||
/// <see cref="SharedTrust.ScriptTrustPolicy.ForbiddenScopes"/> /
|
||||
/// <see cref="SharedTrust.ScriptTrustPolicy.AllowedExceptions"/> deny-list via
|
||||
/// <see cref="IsForbiddenName"/>. 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.
|
||||
///
|
||||
/// This is the position-aware, namespace-level editor advisory: it keeps the
|
||||
/// line/column markers Monaco needs. The authoritative run verdict lives in
|
||||
/// <see cref="EnforceTrustModel"/>, which delegates to the shared
|
||||
/// <see cref="SharedTrust.ScriptTrustValidator"/> (and additionally covers
|
||||
/// reflection-gateway members + dynamic/Activator hardening that the editor
|
||||
/// advisory does not mark).
|
||||
/// </summary>
|
||||
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
|
||||
{
|
||||
@@ -1208,25 +1205,45 @@ public class ScriptAnalysisService
|
||||
/// <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.
|
||||
/// name. M3.5: the allowed-exception carve-out is applied at the WHOLE-SYMBOL
|
||||
/// level (mirroring the shared <see cref="SharedTrust.ScriptTrustValidator"/>
|
||||
/// semantic pass), so a type whose full name is an allowed exception
|
||||
/// (e.g. <c>System.Threading.CancellationTokenSource</c>) is NOT flagged even
|
||||
/// though its bare containing namespace <c>System.Threading</c> is a forbidden
|
||||
/// root. Without the symbol-level check the namespace scope alone would
|
||||
/// false-positive the allowed cancellation types.
|
||||
/// </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;
|
||||
var names = QualifiedNamesOf(symbol).ToList();
|
||||
if (names.Count == 0) return null;
|
||||
|
||||
// Allowed exception on ANY of the symbol's qualified names wins outright —
|
||||
// the rest of System.Threading stays forbidden, but Tasks +
|
||||
// CancellationToken(Source) survive.
|
||||
if (names.Any(n =>
|
||||
SharedTrust.ScriptTrustPolicy.AllowedExceptions.Any(a => IsUnderScope(n, a))))
|
||||
return null;
|
||||
|
||||
return names.FirstOrDefault(IsForbiddenName);
|
||||
}
|
||||
|
||||
/// <summary>Fully-qualified names a symbol reference implicates for trust-model checking.</summary>
|
||||
/// <summary>
|
||||
/// Fully-qualified names a symbol reference implicates for trust-model checking.
|
||||
/// M3.5: a bare namespace symbol is intentionally ignored — mirroring the shared
|
||||
/// <see cref="SharedTrust.ScriptTrustValidator"/> semantic pass. A namespace name
|
||||
/// on its own performs no action; harm requires referencing a type or member, so
|
||||
/// flagging the bare <c>System.Threading</c> qualifier of an otherwise-allowed
|
||||
/// type (<c>System.Threading.CancellationTokenSource</c>) would be a false
|
||||
/// positive. The forbidden-<c>using</c>-directive path is checked separately on
|
||||
/// the directive text, so namespace imports are still caught.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> QualifiedNamesOf(ISymbol symbol)
|
||||
{
|
||||
switch (symbol)
|
||||
{
|
||||
case INamespaceSymbol { IsGlobalNamespace: false } ns:
|
||||
yield return ns.ToDisplayString();
|
||||
case INamespaceSymbol:
|
||||
break;
|
||||
case ITypeSymbol type:
|
||||
if (type.ContainingNamespace is { IsGlobalNamespace: false } tn)
|
||||
@@ -1249,23 +1266,74 @@ public class ScriptAnalysisService
|
||||
? ns.ToDisplayString() + "." + type.Name
|
||||
: type.Name;
|
||||
|
||||
private static bool IsForbiddenName(string qualifiedName) =>
|
||||
ForbiddenNamespacePrefixes.Any(p =>
|
||||
qualifiedName == p || qualifiedName.StartsWith(p + ".", StringComparison.Ordinal));
|
||||
/// <summary>
|
||||
/// M3.5: the forbidden-namespace verdict for the editor walker is sourced
|
||||
/// from the shared <see cref="SharedTrust.ScriptTrustPolicy"/> — the single
|
||||
/// deny-list the run gate (<see cref="ScriptTrustValidator"/>) also uses, so
|
||||
/// the editor squiggle and the run gate agree on which namespaces are
|
||||
/// forbidden. A name under a <see cref="SharedTrust.ScriptTrustPolicy.ForbiddenScopes"/>
|
||||
/// root is forbidden UNLESS it is also under a
|
||||
/// <see cref="SharedTrust.ScriptTrustPolicy.AllowedExceptions"/> entry
|
||||
/// (async/await + cancellation tokens survive the System.Threading block).
|
||||
/// </summary>
|
||||
private static bool IsForbiddenName(string qualifiedName)
|
||||
{
|
||||
if (SharedTrust.ScriptTrustPolicy.AllowedExceptions.Any(a => IsUnderScope(qualifiedName, a)))
|
||||
return false;
|
||||
return SharedTrust.ScriptTrustPolicy.ForbiddenScopes.Any(f => IsUnderScope(qualifiedName, f));
|
||||
}
|
||||
|
||||
private static bool IsUnderScope(string actual, string root) =>
|
||||
actual.Equals(root, StringComparison.Ordinal)
|
||||
|| actual.StartsWith(root + ".", 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.
|
||||
/// Pre-execution trust-model gate (CentralUI-001). M3.5: the VERDICT is
|
||||
/// delegated to the shared <see cref="SharedTrust.ScriptTrustValidator"/> —
|
||||
/// the single source of truth the editor squiggle deny-list also derives from,
|
||||
/// so the run gate and the editor agree. The shared validator additionally
|
||||
/// enforces reflection-gateway members + dynamic/Activator hardening beyond the
|
||||
/// editor's namespace-level advisory. An empty result means clear to run.
|
||||
///
|
||||
/// The compilation's own metadata references are forwarded to the shared
|
||||
/// validator as extra references so its semantic pass resolves symbols against
|
||||
/// the SAME metadata the script was compiled against (the full BCL surface,
|
||||
/// including the SandboxScriptHost globals assembly). This keeps the gate's
|
||||
/// resolution identical to the editor walker's — e.g. a bare <c>Process</c>
|
||||
/// reached through <c>using System.Diagnostics;</c> resolves to
|
||||
/// <c>System.Diagnostics.Process</c> and is caught. Forwarding references never
|
||||
/// whitelists anything: the deny-list still applies regardless of what is
|
||||
/// referenced.
|
||||
///
|
||||
/// The shared validator returns positionless messages; the run gate doesn't
|
||||
/// need precise positions (only the verdict), so each violation is surfaced as a
|
||||
/// single error marker anchored at the script start. This is a static semantic
|
||||
/// gate, not a process sandbox — full isolation would require 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();
|
||||
|
||||
var violations = SharedTrust.ScriptTrustValidator.FindViolations(
|
||||
tree.ToString(), compilation.References);
|
||||
if (violations.Count == 0) return Array.Empty<DiagnosticMarker>();
|
||||
|
||||
// Positionless verdict → one error marker per violation at the script
|
||||
// start. Code SCADA002 (forbidden API reference) matches the editor's
|
||||
// semantic-marker code, so existing gate assertions ("SCADA001"/"SCADA002")
|
||||
// continue to hold.
|
||||
return violations
|
||||
.Select(v => new DiagnosticMarker(
|
||||
Severity: 8,
|
||||
StartLineNumber: 1,
|
||||
StartColumn: 1,
|
||||
EndLineNumber: 1,
|
||||
EndColumn: 2,
|
||||
Message: $"{v} — not allowed in scripts (script trust model).",
|
||||
Code: "SCADA002"))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static CompletionItem ToCompletionItem(ISymbol symbol)
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.TemplateEngine/ZB.MOM.WW.ScadaBridge.TemplateEngine.csproj" />
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.DeploymentManager/ZB.MOM.WW.ScadaBridge.DeploymentManager.csproj" />
|
||||
|
||||
+59
-1
@@ -54,11 +54,14 @@ public class ScriptAnalysisServiceTests
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("System.Diagnostics")]
|
||||
[InlineData("System.Reflection")]
|
||||
[InlineData("System.Net")]
|
||||
[InlineData("System.Threading")]
|
||||
public void ForbiddenUsing_AllBannedNamespaces(string ns)
|
||||
{
|
||||
// M3.5: the editor deny-list is sourced from the shared ScriptTrustPolicy.
|
||||
// System.Threading is now a forbidden root (only Tasks +
|
||||
// CancellationToken(Source) are excepted).
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest($"using {ns};"));
|
||||
Assert.Contains(resp.Markers, m => m.Code == "SCADA001" && m.Message.Contains(ns));
|
||||
}
|
||||
@@ -71,6 +74,61 @@ public class ScriptAnalysisServiceTests
|
||||
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("File"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemDiagnosticsNamespace_NoLongerWholesaleForbidden()
|
||||
{
|
||||
// M3.5: the unified policy forbids only System.Diagnostics.Process, not the
|
||||
// whole System.Diagnostics namespace — so a bare `using System.Diagnostics;`
|
||||
// is no longer flagged (Stopwatch & friends are allowed).
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest("using System.Diagnostics;"));
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code == "SCADA001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopwatchUsage_IsAllowedUnderUnifiedPolicy()
|
||||
{
|
||||
// System.Diagnostics.Stopwatch is NOT under the forbidden
|
||||
// System.Diagnostics.Process scope — it must not be flagged.
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||
"using System.Diagnostics; var sw = Stopwatch.StartNew(); return sw.ElapsedMilliseconds;"));
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUsage_IsForbiddenUnderUnifiedPolicy()
|
||||
{
|
||||
// System.Diagnostics.Process IS the forbidden scope — flagged via the
|
||||
// semantic marker even though the parent namespace is allowed.
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||
"using System.Diagnostics; var p = Process.GetCurrentProcess(); return p.Id;"));
|
||||
Assert.Contains(resp.Markers, m => m.Code == "SCADA002" && m.Message.Contains("Process"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Monitor")]
|
||||
[InlineData("Interlocked")]
|
||||
[InlineData("Mutex")]
|
||||
public void ThreadingPrimitives_NowFlagged_UnderUnifiedPolicy(string typeName)
|
||||
{
|
||||
// CentralUI's old deny-list allowed most of System.Threading (only Thread +
|
||||
// Tasks.Sources were blocked). The unified policy forbids ALL of
|
||||
// System.Threading except Tasks + CancellationToken(Source), so these
|
||||
// previously-clean primitives are now flagged by the editor.
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||
$"using System.Threading; var x = typeof({typeName}); return 1;"));
|
||||
Assert.Contains(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationToken_StaysAllowed_UnderUnifiedPolicy()
|
||||
{
|
||||
// The AllowedExceptions carve-out keeps CancellationToken(Source) clean even
|
||||
// though the rest of System.Threading is forbidden.
|
||||
var resp = _svc.Diagnose(new DiagnoseRequest(
|
||||
"var cts = new System.Threading.CancellationTokenSource(); return cts.Token.IsCancellationRequested;"));
|
||||
Assert.DoesNotContain(resp.Markers, m => m.Code is "SCADA001" or "SCADA002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserIdentifierNamedFile_DoesNotFalsePositive()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user