refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// WP-19: Script Trust Model — compiles C# scripts using Roslyn with restricted API access.
|
||||
/// Forbidden APIs: System.IO, Process, Threading (except async/await), Reflection,
|
||||
/// System.Net.Sockets, System.Net.Http.
|
||||
/// </summary>
|
||||
public class ScriptCompilationService
|
||||
{
|
||||
private readonly ILogger<ScriptCompilationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Forbidden API roots. Each entry is matched as a prefix against both the resolved
|
||||
/// symbol's containing namespace and its fully-qualified containing type name, so an
|
||||
/// entry may name a whole namespace ("System.IO") or a single type
|
||||
/// ("System.Diagnostics.Process").
|
||||
/// </summary>
|
||||
private static readonly string[] ForbiddenNamespaces =
|
||||
[
|
||||
"System.IO",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading",
|
||||
"System.Reflection",
|
||||
"System.Net.Sockets",
|
||||
"System.Net.Http"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Specific namespaces/types allowed even though they sit under a forbidden root.
|
||||
/// async/await and cancellation tokens are OK despite System.Threading being blocked.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedExceptions =
|
||||
[
|
||||
"System.Threading.Tasks",
|
||||
"System.Threading.CancellationToken",
|
||||
"System.Threading.CancellationTokenSource"
|
||||
];
|
||||
|
||||
/// <summary>Initializes a new instance of the ScriptCompilationService class.</summary>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public ScriptCompilationService(ILogger<ScriptCompilationService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SiteRuntime-011: validates that the script does not reference forbidden APIs.
|
||||
///
|
||||
/// Validation is performed with Roslyn semantic analysis rather than a raw substring
|
||||
/// scan of the source text. The script is parsed and a semantic model is built; every
|
||||
/// identifier, type reference, member access, and object creation is resolved to its
|
||||
/// symbol and the symbol's containing namespace is checked against the forbidden list.
|
||||
///
|
||||
/// This is reliable in both directions a textual scan was not:
|
||||
/// - it catches forbidden types regardless of how they are written (<c>global::</c>
|
||||
/// prefixes, aliases, transitively-imported namespaces) because it inspects the
|
||||
/// resolved symbol, not the spelling;
|
||||
/// - it does not raise false positives for the namespace string appearing in a
|
||||
/// comment, a string literal, or an unrelated identifier.
|
||||
///
|
||||
/// Returns a list of violation messages, empty if clean.
|
||||
/// </summary>
|
||||
/// <param name="code">The script code to validate.</param>
|
||||
public IReadOnlyList<string> ValidateTrustModel(string code)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(
|
||||
code, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
|
||||
var compilation = CSharpCompilation.CreateScriptCompilation(
|
||||
"TrustValidation",
|
||||
tree,
|
||||
ScriptReferences,
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
var root = tree.GetRoot();
|
||||
|
||||
// Deduplicate so a forbidden symbol used many times is reported once but
|
||||
// distinct forbidden symbols are all reported.
|
||||
var violations = new SortedSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var node in root.DescendantNodes())
|
||||
{
|
||||
// Only inspect nodes that name a type or member; skip declarations,
|
||||
// string literals and comments entirely. Member-access and qualified-name
|
||||
// parents are evaluated as a whole, so their nested name parts are skipped.
|
||||
if (node is not (SimpleNameSyntax or MemberAccessExpressionSyntax
|
||||
or QualifiedNameSyntax or ObjectCreationExpressionSyntax))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = model.GetSymbolInfo(node);
|
||||
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
|
||||
|
||||
// The set of fully-qualified scopes this reference touches: the resolved
|
||||
// symbol's containing namespace and type, or — when the symbol could not
|
||||
// be resolved (a type from an unreferenced assembly) — the syntactic
|
||||
// fully-qualified name written in source as a safe fallback.
|
||||
var scopes = symbol != null
|
||||
? GetSymbolScopes(symbol)
|
||||
: GetSyntacticScopes(node);
|
||||
if (scopes.Count == 0)
|
||||
continue;
|
||||
|
||||
var forbidden = ForbiddenNamespaces.FirstOrDefault(
|
||||
f => scopes.Any(s => IsUnderScope(s, f)));
|
||||
if (forbidden == null)
|
||||
continue;
|
||||
|
||||
// Allow specific exception namespaces/types (async/await, cancellation).
|
||||
if (scopes.Any(s => AllowedExceptions.Any(a => IsUnderScope(s, a))))
|
||||
continue;
|
||||
|
||||
var name = symbol?.Name ?? node.ToString();
|
||||
violations.Add($"Forbidden API reference: '{forbidden}' ({scopes[0]}.{name})");
|
||||
}
|
||||
|
||||
return violations.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the fully-qualified scopes a resolved symbol belongs to — its containing
|
||||
/// namespace and, for a type or member, the fully-qualified containing type. A bare
|
||||
/// namespace symbol is intentionally ignored: a namespace name on its own performs
|
||||
/// no action; harm requires referencing a type or a member.
|
||||
/// </summary>
|
||||
private static List<string> GetSymbolScopes(ISymbol symbol)
|
||||
{
|
||||
var scopes = new List<string>();
|
||||
|
||||
switch (symbol)
|
||||
{
|
||||
case INamespaceSymbol:
|
||||
// A namespace reference alone is harmless — skip it. (This avoids a
|
||||
// false positive on the "System.Threading" qualifier of the allowed
|
||||
// "System.Threading.Tasks.Task".)
|
||||
break;
|
||||
case ITypeSymbol typeSymbol:
|
||||
scopes.Add(typeSymbol.ToDisplayString());
|
||||
if (typeSymbol.ContainingNamespace is { IsGlobalNamespace: false } typeNs)
|
||||
scopes.Add(typeNs.ToDisplayString());
|
||||
break;
|
||||
default:
|
||||
if (symbol.ContainingType != null)
|
||||
{
|
||||
scopes.Add(symbol.ContainingType.ToDisplayString());
|
||||
if (symbol.ContainingType.ContainingNamespace is { IsGlobalNamespace: false } memberNs)
|
||||
scopes.Add(memberNs.ToDisplayString());
|
||||
}
|
||||
else if (symbol.ContainingNamespace is { IsGlobalNamespace: false } ns)
|
||||
{
|
||||
scopes.Add(ns.ToDisplayString());
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return scopes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback used when a name could not be resolved to a symbol (e.g. a type from an
|
||||
/// assembly the script is not allowed to reference). The fully-qualified name as
|
||||
/// written in source is used directly — a script that names
|
||||
/// <c>System.Net.Http.HttpClient</c> is still rejected even though that assembly is
|
||||
/// deliberately absent from the script's metadata references.
|
||||
/// </summary>
|
||||
private static List<string> GetSyntacticScopes(SyntaxNode node)
|
||||
{
|
||||
// A dotted name written in source is itself the fully-qualified scope. Only
|
||||
// consider names that actually contain a dot — bare local identifiers cannot
|
||||
// reach a forbidden namespace.
|
||||
var text = node switch
|
||||
{
|
||||
QualifiedNameSyntax q => q.ToString(),
|
||||
MemberAccessExpressionSyntax m => m.ToString(),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
// Strip whitespace/newlines that a multi-line member-access chain may contain.
|
||||
text = new string(text.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
|
||||
return string.IsNullOrEmpty(text) || !text.Contains('.')
|
||||
? []
|
||||
: [text];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if <paramref name="actual"/> is exactly, or nested within,
|
||||
/// <paramref name="root"/> (e.g. "System.IO.Compression" is under "System.IO",
|
||||
/// "System.Diagnostics.Process" is under "System.Diagnostics.Process").
|
||||
/// </summary>
|
||||
private static bool IsUnderScope(string actual, string root)
|
||||
=> actual.Equals(root, StringComparison.Ordinal)
|
||||
|| actual.StartsWith(root + ".", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Assemblies referenced by compiled scripts. Shared between the Roslyn scripting
|
||||
/// options and the semantic-analysis compilation built for trust validation
|
||||
/// (SiteRuntime-011), so the validator resolves symbols against exactly the same
|
||||
/// metadata the script is compiled against.
|
||||
/// </summary>
|
||||
private static readonly System.Reflection.Assembly[] ScriptAssemblies =
|
||||
[
|
||||
typeof(object).Assembly,
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(Math).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||
typeof(Commons.Types.DynamicJsonElement).Assembly
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Metadata references for the trust-validation semantic compilation.
|
||||
/// </summary>
|
||||
private static readonly MetadataReference[] ScriptReferences =
|
||||
ScriptAssemblies
|
||||
.Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Shared Roslyn scripting options (references + imports) used by both full
|
||||
/// script compilation and trigger-expression compilation.
|
||||
/// </summary>
|
||||
private static ScriptOptions BuildScriptOptions() => ScriptOptions.Default
|
||||
.WithReferences(ScriptAssemblies)
|
||||
.WithImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||
/// and parameters dictionary, and returns an object? result.
|
||||
/// </summary>
|
||||
/// <param name="scriptName">The name of the script.</param>
|
||||
/// <param name="code">The script code to compile.</param>
|
||||
public ScriptCompilationResult Compile(string scriptName, string code)
|
||||
=> CompileCore(scriptName, code, typeof(ScriptGlobals));
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a bare C# boolean trigger expression against the restricted
|
||||
/// read-only <see cref="TriggerExpressionGlobals"/>. The expression is a
|
||||
/// trailing expression (no <c>return</c>); Roslyn scripting yields its
|
||||
/// value, which the caller coerces to <c>bool</c>. Reuses the same script
|
||||
/// options and forbidden-API trust validation as <see cref="Compile"/>.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the trigger expression.</param>
|
||||
/// <param name="expression">The trigger expression to compile.</param>
|
||||
public ScriptCompilationResult CompileTriggerExpression(string name, string expression)
|
||||
=> CompileCore(name, expression, typeof(TriggerExpressionGlobals));
|
||||
|
||||
/// <summary>
|
||||
/// Shared compilation path: validates the trust model, builds the script
|
||||
/// against the given globals type, and returns the compiled result.
|
||||
/// </summary>
|
||||
private ScriptCompilationResult CompileCore(string name, string code, Type globalsType)
|
||||
{
|
||||
// Validate trust model
|
||||
var violations = ValidateTrustModel(code);
|
||||
if (violations.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} failed trust validation: {Violations}",
|
||||
name, string.Join("; ", violations));
|
||||
return ScriptCompilationResult.Failed(violations);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var script = CSharpScript.Create<object?>(
|
||||
code,
|
||||
BuildScriptOptions(),
|
||||
globalsType: globalsType);
|
||||
|
||||
var diagnostics = script.Compile();
|
||||
var errors = diagnostics
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.Select(d => d.GetMessage())
|
||||
.ToList();
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Script {Script} compilation failed: {Errors}",
|
||||
name, string.Join("; ", errors));
|
||||
return ScriptCompilationResult.Failed(errors);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Script {Script} compiled successfully", name);
|
||||
return ScriptCompilationResult.Succeeded(script);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error compiling script {Script}", name);
|
||||
return ScriptCompilationResult.Failed([$"Compilation exception: {ex.Message}"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of script compilation, containing either the compiled script or error messages.
|
||||
/// </summary>
|
||||
public class ScriptCompilationResult
|
||||
{
|
||||
/// <summary>Indicates whether compilation succeeded.</summary>
|
||||
public bool IsSuccess { get; }
|
||||
/// <summary>The compiled script, or null if compilation failed.</summary>
|
||||
public Script<object?>? CompiledScript { get; }
|
||||
/// <summary>List of error messages, empty if compilation succeeded.</summary>
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
private ScriptCompilationResult(bool success, Script<object?>? script, IReadOnlyList<string> errors)
|
||||
{
|
||||
IsSuccess = success;
|
||||
CompiledScript = script;
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
/// <summary>Creates a successful compilation result.</summary>
|
||||
/// <param name="script">The compiled script.</param>
|
||||
public static ScriptCompilationResult Succeeded(Script<object?> script) =>
|
||||
new(true, script, []);
|
||||
|
||||
/// <summary>Creates a failed compilation result.</summary>
|
||||
/// <param name="errors">List of error messages.</param>
|
||||
public static ScriptCompilationResult Failed(IReadOnlyList<string> errors) =>
|
||||
new(false, null, errors);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global variables available to compiled scripts. The ScriptRuntimeContext is injected
|
||||
/// as the "Instance" global, and parameters are available via "Parameters".
|
||||
/// </summary>
|
||||
public class ScriptGlobals
|
||||
{
|
||||
/// <summary>The script runtime context providing access to instance state.</summary>
|
||||
public ScriptRuntimeContext Instance { get; set; } = null!;
|
||||
/// <summary>Script parameters passed by the caller.</summary>
|
||||
public ScriptParameters Parameters { get; set; } = new ScriptParameters();
|
||||
/// <summary>Cancellation token for script execution.</summary>
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Alarm context when this script is invoked as an on-trigger handler.
|
||||
/// Null for instance scripts, shared scripts, and inbound-API-routed
|
||||
/// scripts. Lets on-trigger scripts read the firing alarm's Name, Level
|
||||
/// (HiLo only), Priority, and per-band Message to branch routing logic.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.AlarmContext? Alarm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where this script sits in the composition tree. Defaults to root for
|
||||
/// scripts on top-level templates; a flattened composed script gets
|
||||
/// SelfPath = "TempSensor" (etc.) and a ParentPath set to one level up.
|
||||
/// </summary>
|
||||
public Commons.Types.Scripts.ScriptScope Scope { get; set; } =
|
||||
Commons.Types.Scripts.ScriptScope.Root;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level ExternalSystem access for scripts (delegates to Instance.ExternalSystem).
|
||||
/// Usage: ExternalSystem.Call("systemName", "methodName", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.ExternalSystemHelper ExternalSystem => Instance.ExternalSystem;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Database access for scripts (delegates to Instance.Database).
|
||||
/// Usage: Database.Connection("name") or Database.CachedWrite("name", "sql", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.DatabaseHelper Database => Instance.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Notify access for scripts (delegates to Instance.Notify).
|
||||
/// Usage: Notify.To("listName").Send("subject", "message")
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.NotifyHelper Notify => Instance.Notify;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level Scripts access for shared script calls (delegates to Instance.Scripts).
|
||||
/// Usage: Scripts.CallShared("scriptName", params)
|
||||
/// </summary>
|
||||
public ScriptRuntimeContext.ScriptCallHelper Scripts => Instance.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Read/write the current template's attributes by name. Resolves to the
|
||||
/// canonical name for the script's scope, so a script on a composed
|
||||
/// TempSensor reads its own Temperature via <c>Attributes["Temperature"]</c>.
|
||||
/// </summary>
|
||||
public AttributeAccessor Attributes => new(Instance, Scope.SelfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Indexed access to child compositions.
|
||||
/// <c>Children["TempSensor"].Attributes["Temperature"]</c> reads the
|
||||
/// composed child's attribute. <c>Children["TempSensor"].CallScript("Sample")</c>
|
||||
/// invokes a script on the child.
|
||||
/// </summary>
|
||||
public ChildrenAccessor Children => new(Instance, Scope.SelfPath);
|
||||
|
||||
/// <summary>
|
||||
/// Parent composition (null when this script is on a root-level template).
|
||||
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
||||
/// <c>Parent.CallScript("Trip")</c> invokes a parent script.
|
||||
/// </summary>
|
||||
public CompositionAccessor? Parent =>
|
||||
Scope.ParentPath == null ? null : new CompositionAccessor(Instance, Scope.ParentPath);
|
||||
}
|
||||
Reference in New Issue
Block a user