feat(scriptanalysis): M3.1 shared trust validator + compiler + compile surfaces + tests
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.ManagementService/ZB.MOM.WW.ScadaBridge.ManagementService.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.CLI/ZB.MOM.WW.ScadaBridge.CLI.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.Transport/ZB.MOM.WW.ScadaBridge.Transport.csproj" />
|
||||
<Project Path="src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests.csproj" />
|
||||
@@ -51,5 +52,6 @@
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/ZB.MOM.WW.ScadaBridge.Transport.Tests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests/ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests.csproj" />
|
||||
<Project Path="tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// M3.1: the single authoritative Roslyn compile gate. Ported from the
|
||||
/// SiteRuntime <c>ScriptCompilationService.CompileCore</c>, but returns
|
||||
/// diagnostic messages rather than a compiled <c>Script</c> delegate — this is
|
||||
/// the design-time gate (the deploy-time validation that previously relied on
|
||||
/// the FAKE substring + brace-balance check in
|
||||
/// <c>TemplateEngine/Validation/ScriptCompiler.cs</c>), which needs to know
|
||||
/// whether the script <em>compiles</em>, not to execute it.
|
||||
/// </summary>
|
||||
public static class RoslynScriptCompiler
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses the script as C# script source and returns syntax-error diagnostic
|
||||
/// messages (severity Error only). Empty list means the script parses.
|
||||
/// </summary>
|
||||
/// <param name="code">The C# script source to parse.</param>
|
||||
/// <returns>Error-severity parse diagnostic messages; empty if the script parses.</returns>
|
||||
public static IReadOnlyList<string> ParseDiagnostics(string code)
|
||||
{
|
||||
var tree = CSharpSyntaxTree.ParseText(
|
||||
code, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
|
||||
return tree.GetDiagnostics()
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.Select(d => d.GetMessage())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compiles the script against the trust-model references and imports and
|
||||
/// returns error-severity compilation diagnostic messages. Empty list means
|
||||
/// the script compiles cleanly against <paramref name="globalsType"/>.
|
||||
/// </summary>
|
||||
/// <param name="code">The C# script source to compile.</param>
|
||||
/// <param name="globalsType">
|
||||
/// Optional globals type the script binds against — e.g.
|
||||
/// <c>ScriptCompileSurface</c> for instance/shared scripts or
|
||||
/// <c>TriggerCompileSurface</c> for trigger expressions.
|
||||
/// </param>
|
||||
/// <param name="extraReferences">Optional additional metadata references.</param>
|
||||
/// <param name="extraImports">Optional additional namespace imports.</param>
|
||||
/// <returns>Error-severity compile diagnostic messages; empty if the script compiles.</returns>
|
||||
public static IReadOnlyList<string> Compile(
|
||||
string code,
|
||||
Type? globalsType = null,
|
||||
IEnumerable<MetadataReference>? extraReferences = null,
|
||||
IEnumerable<string>? extraImports = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var references = ScriptTrustPolicy.DefaultReferences.ToList();
|
||||
if (extraReferences != null)
|
||||
references.AddRange(extraReferences);
|
||||
|
||||
var imports = ScriptTrustPolicy.DefaultImports.AsEnumerable();
|
||||
if (extraImports != null)
|
||||
imports = imports.Concat(extraImports);
|
||||
|
||||
var options = ScriptOptions.Default
|
||||
.WithReferences(references)
|
||||
.WithImports(imports);
|
||||
|
||||
var script = CSharpScript.Create<object?>(code, options, globalsType);
|
||||
var diagnostics = script.Compile();
|
||||
|
||||
return diagnostics
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.Select(d => d.GetMessage())
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return [$"Compilation exception: {ex.Message}"];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Data.Common;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// M3.1: a <b>compile-only</b> globals type that mirrors the real SiteRuntime
|
||||
/// <c>ScriptGlobals</c> (+ <c>ScriptRuntimeContext</c> helper surface)
|
||||
/// member-for-member, so a real instance / shared / on-trigger-handler script
|
||||
/// BINDS against it at design time. It is NEVER executed — every member body
|
||||
/// throws <see cref="NotSupportedException"/> or returns <c>default</c>. The
|
||||
/// design-time deploy gate (M3.5) compiles candidate scripts against this type
|
||||
/// via <see cref="RoslynScriptCompiler.Compile(string, Type, System.Collections.Generic.IEnumerable{Microsoft.CodeAnalysis.MetadataReference}, System.Collections.Generic.IEnumerable{string})"/>
|
||||
/// to catch undefined symbols and signature mismatches without touching the
|
||||
/// site runtime.
|
||||
///
|
||||
/// <para>
|
||||
/// Keeping this surface faithful is enforced by the
|
||||
/// <c>RoslynScriptCompilerTests</c> "representative real script" corpus — if a
|
||||
/// member or signature drifts from the runtime <c>ScriptGlobals</c>, that test
|
||||
/// fails.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ScriptCompileSurface
|
||||
{
|
||||
private const string CompileOnly = "compile-only surface";
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Instance</c>.</summary>
|
||||
public CompileInstance Instance { get; set; } = null!;
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Parameters</c>.</summary>
|
||||
public ScriptParameters Parameters { get; set; } = new();
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.CancellationToken</c>.</summary>
|
||||
public CancellationToken CancellationToken { get; set; }
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Alarm</c>.</summary>
|
||||
public AlarmContext? Alarm { get; set; }
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Scope</c>.</summary>
|
||||
public ScriptScope Scope { get; set; } = ScriptScope.Root;
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.ExternalSystem</c> (delegates to Instance).</summary>
|
||||
public CompileExternalSystem ExternalSystem => Instance.ExternalSystem;
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Database</c>.</summary>
|
||||
public CompileDatabase Database => Instance.Database;
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Notify</c>.</summary>
|
||||
public CompileNotify Notify => Instance.Notify;
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Scripts</c>.</summary>
|
||||
public CompileScripts Scripts => Instance.Scripts;
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Attributes</c>.</summary>
|
||||
public CompileAttributeAccessor Attributes => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Children</c>.</summary>
|
||||
public CompileChildrenAccessor Children => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Parent</c>.</summary>
|
||||
public CompileCompositionAccessor? Parent => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary>
|
||||
public sealed class CompileInstance
|
||||
{
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.GetAttribute</c>.</summary>
|
||||
public Task<object?> GetAttribute(string attributeName) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.SetAttribute</c>.</summary>
|
||||
public Task SetAttribute(string attributeName, string value) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.CallScript</c>.</summary>
|
||||
public Task<object?> CallScript(string scriptName, object? parameters = null) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.ExternalSystem</c>.</summary>
|
||||
public CompileExternalSystem ExternalSystem => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Database</c>.</summary>
|
||||
public CompileDatabase Database => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Notify</c>.</summary>
|
||||
public CompileNotify Notify => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Scripts</c>.</summary>
|
||||
public CompileScripts Scripts => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Tracking</c>.</summary>
|
||||
public CompileTracking Tracking => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.ExternalSystemHelper</c>.</summary>
|
||||
public sealed class CompileExternalSystem
|
||||
{
|
||||
/// <summary>Mirrors <c>ExternalSystemHelper.Call</c>.</summary>
|
||||
public Task<ExternalCallResult> Call(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ExternalSystemHelper.CachedCall</c>.</summary>
|
||||
public Task<TrackedOperationId> CachedCall(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.DatabaseHelper</c>.</summary>
|
||||
public sealed class CompileDatabase
|
||||
{
|
||||
/// <summary>Mirrors <c>DatabaseHelper.Connection</c>.</summary>
|
||||
public Task<DbConnection> Connection(string name, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>DatabaseHelper.CachedWrite</c>.</summary>
|
||||
public Task<TrackedOperationId> CachedWrite(
|
||||
string name,
|
||||
string sql,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.NotifyHelper</c>.</summary>
|
||||
public sealed class CompileNotify
|
||||
{
|
||||
/// <summary>Mirrors <c>NotifyHelper.To</c>.</summary>
|
||||
public CompileNotifyTarget To(string listName) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>NotifyHelper.Status</c>.</summary>
|
||||
public Task<NotificationDeliveryStatus> Status(string notificationId) => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.NotifyTarget</c>.</summary>
|
||||
public sealed class CompileNotifyTarget
|
||||
{
|
||||
/// <summary>Mirrors <c>NotifyTarget.Send</c>.</summary>
|
||||
public Task<string> Send(string subject, string message, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.ScriptCallHelper</c>.</summary>
|
||||
public sealed class CompileScripts
|
||||
{
|
||||
/// <summary>Mirrors <c>ScriptCallHelper.CallShared</c>.</summary>
|
||||
public Task<object?> CallShared(
|
||||
string scriptName,
|
||||
object? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.TrackingHelper</c>.</summary>
|
||||
public sealed class CompileTracking
|
||||
{
|
||||
/// <summary>Mirrors <c>TrackingHelper.Status</c>.</summary>
|
||||
public Task<TrackingStatusSnapshot?> Status(
|
||||
TrackedOperationId trackedOperationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>AttributeAccessor</c>.</summary>
|
||||
public sealed class CompileAttributeAccessor
|
||||
{
|
||||
/// <summary>Mirrors <c>AttributeAccessor.this[string]</c>.</summary>
|
||||
public object? this[string key]
|
||||
{
|
||||
get => throw new NotSupportedException(CompileOnly);
|
||||
set => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Mirrors <c>AttributeAccessor.GetAsync</c>.</summary>
|
||||
public Task<object?> GetAsync(string key) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>AttributeAccessor.SetAsync</c>.</summary>
|
||||
public Task SetAsync(string key, object? value) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>AttributeAccessor.Resolve</c>.</summary>
|
||||
public string Resolve(string key) => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ChildrenAccessor</c>.</summary>
|
||||
public sealed class CompileChildrenAccessor
|
||||
{
|
||||
/// <summary>Mirrors <c>ChildrenAccessor.this[string]</c>.</summary>
|
||||
public CompileCompositionAccessor this[string compositionName] => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>CompositionAccessor</c>.</summary>
|
||||
public sealed class CompileCompositionAccessor
|
||||
{
|
||||
/// <summary>Mirrors <c>CompositionAccessor.Attributes</c>.</summary>
|
||||
public CompileAttributeAccessor Attributes => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>CompositionAccessor.CallScript</c>.</summary>
|
||||
public Task<object?> CallScript(string scriptName, object? parameters = null) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>CompositionAccessor.ResolveScript</c>.</summary>
|
||||
public string ResolveScript(string scriptName) => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>CompositionAccessor.Path</c>.</summary>
|
||||
public string Path => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// M3.1: the single authoritative source of truth for the ScadaBridge script
|
||||
/// trust model. Previously the forbidden-API deny-list, allowed exceptions,
|
||||
/// reflection-gateway member names, default metadata references, and default
|
||||
/// imports were duplicated (and disagreed) across four call sites — the
|
||||
/// SiteRuntime <c>ScriptCompilationService</c>, the InboundAPI
|
||||
/// <c>ForbiddenApiChecker</c>, and the design-time deploy gate. This class
|
||||
/// fuses them into one collection set that <see cref="ScriptTrustValidator"/>
|
||||
/// and <see cref="RoslynScriptCompiler"/> consume; the four consumers delegate
|
||||
/// here in later tasks (M3.2–M3.5).
|
||||
///
|
||||
/// <para>
|
||||
/// The deny-list is intentionally the UNION of the two existing
|
||||
/// implementations — it forbids <c>System.Diagnostics.Process</c> (not all of
|
||||
/// <c>System.Diagnostics</c>, so <c>Stopwatch</c> stays allowed), all of
|
||||
/// <c>System.Net</c>, all of <c>System.Threading</c> except Tasks /
|
||||
/// CancellationToken(Source), plus <c>System.Reflection</c>,
|
||||
/// <c>System.Runtime.InteropServices</c>, and <c>Microsoft.Win32</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ScriptTrustPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Forbidden API roots. Each entry is matched as a prefix against the
|
||||
/// resolved symbol's containing namespace and fully-qualified containing
|
||||
/// type — an entry may name a whole namespace ("System.IO") or a single
|
||||
/// type ("System.Diagnostics.Process").
|
||||
/// </summary>
|
||||
public static readonly string[] ForbiddenScopes =
|
||||
[
|
||||
"System.IO",
|
||||
"System.Diagnostics.Process",
|
||||
"System.Threading",
|
||||
"System.Reflection",
|
||||
"System.Net",
|
||||
"System.Runtime.InteropServices",
|
||||
"Microsoft.Win32",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Specific namespaces/types allowed even though they sit under a forbidden
|
||||
/// root. async/await and cancellation tokens are OK despite
|
||||
/// <c>System.Threading</c> being blocked.
|
||||
/// </summary>
|
||||
public static readonly string[] AllowedExceptions =
|
||||
[
|
||||
"System.Threading.Tasks",
|
||||
"System.Threading.CancellationToken",
|
||||
"System.Threading.CancellationTokenSource",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Member names that are reflection gateways. Reaching any of these — even
|
||||
/// off a permitted type such as <c>typeof(string)</c> — lets a script
|
||||
/// escape the namespace deny-list (obtain an arbitrary <c>Type</c>, load an
|
||||
/// assembly, late-bind a method). They are rejected regardless of the
|
||||
/// receiver expression.
|
||||
/// </summary>
|
||||
public static readonly HashSet<string> ReflectionGatewayMembers = new(StringComparer.Ordinal)
|
||||
{
|
||||
"GetType",
|
||||
"GetTypeInfo",
|
||||
"Assembly",
|
||||
"Module",
|
||||
"CreateInstance",
|
||||
"InvokeMember",
|
||||
"GetMethod",
|
||||
"GetMethods",
|
||||
"GetConstructor",
|
||||
"GetConstructors",
|
||||
"GetField",
|
||||
"GetFields",
|
||||
"GetProperty",
|
||||
"GetProperties",
|
||||
"GetMember",
|
||||
"GetMembers",
|
||||
"GetRuntimeMethod",
|
||||
"GetRuntimeMethods",
|
||||
"MethodHandle",
|
||||
"TypeHandle",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Bare identifiers that are forbidden outright. <c>dynamic</c> widens
|
||||
/// late-bound member access the static walker cannot see through;
|
||||
/// <c>Activator</c> has no non-reflection use.
|
||||
/// </summary>
|
||||
public static readonly HashSet<string> ForbiddenIdentifiers = new(StringComparer.Ordinal)
|
||||
{
|
||||
"dynamic",
|
||||
"Activator",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Assemblies referenced by compiled scripts. Shared between the Roslyn
|
||||
/// scripting options and the semantic-analysis compilation built for trust
|
||||
/// validation, so the validator resolves symbols against exactly the same
|
||||
/// metadata the script is compiled against.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<Assembly> DefaultAssemblies =
|
||||
[
|
||||
typeof(object).Assembly,
|
||||
typeof(System.Linq.Enumerable).Assembly,
|
||||
typeof(System.Math).Assembly,
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||
typeof(DynamicJsonElement).Assembly,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Metadata references for the trust-validation semantic compilation and
|
||||
/// the design-time script compilation.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<MetadataReference> DefaultReferences =
|
||||
DefaultAssemblies
|
||||
.Select(a => (MetadataReference)MetadataReference.CreateFromFile(a.Location))
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Default namespace imports made available to compiled scripts.
|
||||
/// </summary>
|
||||
public static readonly string[] DefaultImports =
|
||||
[
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// M3.1: the single authoritative script-trust validator, fusing the two
|
||||
/// previously-divergent implementations:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// <b>Semantic symbol analysis</b> (ported from the SiteRuntime
|
||||
/// <c>ScriptCompilationService.ValidateTrustModel</c>): the script is parsed and
|
||||
/// a semantic model built; every identifier / type reference / member access /
|
||||
/// object creation is resolved to its symbol and the symbol's containing
|
||||
/// namespace + type are checked against <see cref="ScriptTrustPolicy.ForbiddenScopes"/>.
|
||||
/// This catches forbidden types regardless of how they are written —
|
||||
/// <c>global::</c> prefixes, aliases, <c>using static</c>, transitively-imported
|
||||
/// namespaces — because it inspects the resolved symbol, not the spelling.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Reflection-gateway + dynamic/Activator hardening</b> (ported from the
|
||||
/// InboundAPI <c>ForbiddenApiChecker.ForbiddenApiWalker</c>): a syntactic walk
|
||||
/// that rejects any member access whose accessed member is in
|
||||
/// <see cref="ScriptTrustPolicy.ReflectionGatewayMembers"/> (regardless of
|
||||
/// receiver — so <c>typeof(string).Assembly.GetType("System.IO.File")</c> is
|
||||
/// caught even though it never spells a forbidden namespace), any identifier in
|
||||
/// <see cref="ScriptTrustPolicy.ForbiddenIdentifiers"/> (<c>dynamic</c>,
|
||||
/// <c>Activator</c>), and forbidden <c>using</c>/qualified-name spellings.
|
||||
/// </item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Keeping BOTH passes is deliberate: the semantic pass catches alias /
|
||||
/// <c>using static</c> / <c>global::</c> escapes; the syntactic pass catches
|
||||
/// reflection reached through members of <em>permitted</em> types. Neither pass
|
||||
/// is a true sandbox — this is best-effort defence-in-depth; genuine containment
|
||||
/// needs a runtime boundary.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ScriptTrustValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Analyses the script source and returns the deduped, sorted list of
|
||||
/// trust-model violations. An empty list means the script is clean.
|
||||
/// </summary>
|
||||
/// <param name="code">The C# script source to analyse.</param>
|
||||
/// <param name="extraReferences">
|
||||
/// Optional additional metadata references to add to
|
||||
/// <see cref="ScriptTrustPolicy.DefaultReferences"/> for semantic
|
||||
/// resolution — e.g. the compile-surface globals assembly so a script
|
||||
/// referencing the API surface resolves cleanly. Forbidden references are
|
||||
/// NOT added here (a script can't reach a forbidden API just because the
|
||||
/// assembly is referenced; the deny-list still applies).
|
||||
/// </param>
|
||||
/// <returns>A list of trust-model violation messages; empty if the script is clean.</returns>
|
||||
public static IReadOnlyList<string> FindViolations(
|
||||
string code,
|
||||
IEnumerable<MetadataReference>? extraReferences = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return Array.Empty<string>();
|
||||
|
||||
var tree = CSharpSyntaxTree.ParseText(
|
||||
code, new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
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);
|
||||
|
||||
// ---- Pass 1: semantic symbol analysis (ported from SiteRuntime) ----
|
||||
var references = ScriptTrustPolicy.DefaultReferences.ToList();
|
||||
if (extraReferences != null)
|
||||
references.AddRange(extraReferences);
|
||||
|
||||
var compilation = CSharpCompilation.CreateScriptCompilation(
|
||||
"TrustValidation",
|
||||
tree,
|
||||
references,
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
|
||||
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
|
||||
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 = ScriptTrustPolicy.ForbiddenScopes.FirstOrDefault(
|
||||
f => scopes.Any(s => IsUnderScope(s, f)));
|
||||
if (forbidden == null)
|
||||
continue;
|
||||
|
||||
// Allow specific exception namespaces/types (async/await, cancellation).
|
||||
if (scopes.Any(s => ScriptTrustPolicy.AllowedExceptions.Any(a => IsUnderScope(s, a))))
|
||||
continue;
|
||||
|
||||
var name = symbol?.Name ?? node.ToString();
|
||||
violations.Add($"Forbidden API reference: '{forbidden}' ({scopes[0]}.{name})");
|
||||
}
|
||||
|
||||
// ---- Pass 2: reflection-gateway + dynamic/Activator hardening
|
||||
// (ported from InboundAPI) ----
|
||||
var walker = new HardeningWalker(violations);
|
||||
walker.Visit(root);
|
||||
|
||||
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>
|
||||
/// True if a dotted name (a <c>using</c> import or qualified name as written)
|
||||
/// is forbidden — under a forbidden scope and not under an allowed exception.
|
||||
/// </summary>
|
||||
private static bool IsForbiddenDottedName(string dottedName)
|
||||
{
|
||||
foreach (var allowed in ScriptTrustPolicy.AllowedExceptions)
|
||||
{
|
||||
if (IsUnderScope(dottedName, allowed))
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var forbidden in ScriptTrustPolicy.ForbiddenScopes)
|
||||
{
|
||||
if (IsUnderScope(dottedName, forbidden))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if a dotted name is exactly an allowed-exception scope OR a strict
|
||||
/// prefix of one — e.g. <c>System.Threading</c> is a prefix of the allowed
|
||||
/// <c>System.Threading.Tasks</c>. Used by the hardening walker to stop
|
||||
/// descending into the namespace qualifier of an allowed type so the bare
|
||||
/// <c>System.Threading</c> qualifier of <c>System.Threading.Tasks.Task</c>
|
||||
/// is not falsely flagged (the semantic pass already ignores bare
|
||||
/// namespaces; this keeps the syntactic pass consistent).
|
||||
/// </summary>
|
||||
private static bool IsAllowedExceptionPrefix(string dottedName)
|
||||
{
|
||||
foreach (var allowed in ScriptTrustPolicy.AllowedExceptions)
|
||||
{
|
||||
if (IsUnderScope(dottedName, allowed)
|
||||
|| allowed.StartsWith(dottedName + ".", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syntactic walker porting the InboundAPI <c>ForbiddenApiWalker</c>
|
||||
/// hardening rules — reflection-gateway members, the <c>dynamic</c> keyword,
|
||||
/// <c>Activator</c>, and forbidden <c>using</c>/qualified-name spellings.
|
||||
/// Writes into the same dedup set as the semantic pass.
|
||||
/// </summary>
|
||||
private sealed class HardeningWalker : CSharpSyntaxWalker
|
||||
{
|
||||
private readonly SortedSet<string> _violations;
|
||||
|
||||
internal HardeningWalker(SortedSet<string> violations) => _violations = violations;
|
||||
|
||||
/// <summary>
|
||||
/// Strips whitespace/newlines a multi-line member-access chain may carry,
|
||||
/// so prefix matching against the dotted policy scopes is reliable.
|
||||
/// </summary>
|
||||
private static string StripWhitespace(string text)
|
||||
=> new(text.Where(c => !char.IsWhiteSpace(c)).ToArray());
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void VisitUsingDirective(UsingDirectiveSyntax node)
|
||||
{
|
||||
if (node.Name is not null && IsForbiddenDottedName(node.Name.ToString()))
|
||||
_violations.Add($"forbidden namespace import '{node.Name}'");
|
||||
|
||||
base.VisitUsingDirective(node);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void VisitQualifiedName(QualifiedNameSyntax node)
|
||||
{
|
||||
var text = StripWhitespace(node.ToString());
|
||||
|
||||
// An allowed-exception name (or a strict prefix of one, e.g.
|
||||
// "System.Threading" under "System.Threading.Tasks") is OK — stop
|
||||
// descending so the bare forbidden-namespace qualifier of an allowed
|
||||
// type is not re-examined and falsely flagged.
|
||||
if (IsAllowedExceptionPrefix(text))
|
||||
return;
|
||||
|
||||
// Check the longest qualified name; do not descend so a single
|
||||
// System.IO.File reference is reported once, not three times.
|
||||
if (IsForbiddenDottedName(text))
|
||||
{
|
||||
_violations.Add($"forbidden type reference '{text}'");
|
||||
return;
|
||||
}
|
||||
|
||||
base.VisitQualifiedName(node);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
|
||||
{
|
||||
var text = StripWhitespace(node.ToString());
|
||||
|
||||
// Reject reflection-gateway members regardless of the receiver — run
|
||||
// this FIRST, before any allowed-exception early-return, so a
|
||||
// gateway member hung off an allowed type
|
||||
// (e.g. System.Threading.Tasks.Task.GetType()) is still caught.
|
||||
// typeof(string).Assembly.GetType("System.IO.File") never spells a
|
||||
// forbidden namespace, but '.Assembly' and '.GetType' appear here as
|
||||
// the accessed member name.
|
||||
var memberName = node.Name.Identifier.ValueText;
|
||||
if (ScriptTrustPolicy.ReflectionGatewayMembers.Contains(memberName))
|
||||
{
|
||||
_violations.Add($"forbidden reflection member access '.{memberName}'");
|
||||
// Still descend: the receiver may contain a further violation.
|
||||
}
|
||||
|
||||
// An allowed-exception member-access (or a strict prefix of one) is
|
||||
// OK — stop descending so the bare forbidden-namespace qualifier of
|
||||
// an allowed type (e.g. the "System.Threading" inside
|
||||
// "System.Threading.Tasks.Task.Delay") is not re-examined and
|
||||
// falsely flagged.
|
||||
if (IsAllowedExceptionPrefix(text))
|
||||
return;
|
||||
|
||||
// Catches fully-qualified expressions such as System.IO.File.Delete(...).
|
||||
if (IsForbiddenDottedName(text))
|
||||
{
|
||||
_violations.Add($"forbidden API access '{text}'");
|
||||
return;
|
||||
}
|
||||
|
||||
base.VisitMemberAccessExpression(node);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void VisitIdentifierName(IdentifierNameSyntax node)
|
||||
{
|
||||
var text = node.Identifier.ValueText;
|
||||
|
||||
// 'dynamic' widens late-bound member access the static walker cannot
|
||||
// see through — reject its use outright. The 'dynamic' contextual
|
||||
// keyword surfaces as an identifier name.
|
||||
if (text == "dynamic")
|
||||
{
|
||||
_violations.Add("forbidden use of the 'dynamic' keyword");
|
||||
return;
|
||||
}
|
||||
|
||||
// A bare reference to a reflection entry-point type. 'Activator' has
|
||||
// no non-reflection use.
|
||||
if (ScriptTrustPolicy.ForbiddenIdentifiers.Contains(text) && text != "dynamic")
|
||||
{
|
||||
_violations.Add($"forbidden reflection type reference '{text}'");
|
||||
return;
|
||||
}
|
||||
|
||||
base.VisitIdentifierName(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// M3.1: a <b>compile-only</b> mirror of the SiteRuntime
|
||||
/// <c>TriggerExpressionGlobals</c> bind surface. A trigger expression is a bare
|
||||
/// boolean expression referencing <c>Attributes["x"]</c> / <c>Children["c"]</c>
|
||||
/// / <c>Parent</c>; the design-time deploy gate (M3.5) compiles candidate
|
||||
/// trigger expressions against this type to catch undefined symbols.
|
||||
///
|
||||
/// <para>
|
||||
/// Only the read-only bind surface is reproduced — the runtime
|
||||
/// <c>ExtractExpression</c> helper and the snapshot-backed constructor are
|
||||
/// intentionally omitted; they are runtime concerns, not part of what an
|
||||
/// expression binds against. Member bodies are compile-only and throw
|
||||
/// <see cref="NotSupportedException"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class TriggerCompileSurface
|
||||
{
|
||||
private const string CompileOnly = "compile-only surface";
|
||||
|
||||
/// <summary>Mirrors <c>TriggerExpressionGlobals.Attributes</c>.</summary>
|
||||
public ReadOnlyAttributes Attributes => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>TriggerExpressionGlobals.Children</c>.</summary>
|
||||
public ReadOnlyChildren Children => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>TriggerExpressionGlobals.Parent</c>.</summary>
|
||||
public ReadOnlyComposition? Parent => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Compile-only mirror of <c>TriggerExpressionGlobals.ReadOnlyAttributes</c>.</summary>
|
||||
public sealed class ReadOnlyAttributes
|
||||
{
|
||||
/// <summary>Mirrors <c>ReadOnlyAttributes.this[string]</c>.</summary>
|
||||
public object? this[string key] => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>TriggerExpressionGlobals.ReadOnlyComposition</c>.</summary>
|
||||
public sealed class ReadOnlyComposition
|
||||
{
|
||||
/// <summary>Mirrors <c>ReadOnlyComposition.Attributes</c>.</summary>
|
||||
public ReadOnlyAttributes Attributes => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>Compile-only mirror of <c>TriggerExpressionGlobals.ReadOnlyChildren</c>.</summary>
|
||||
public sealed class ReadOnlyChildren
|
||||
{
|
||||
/// <summary>Mirrors <c>ReadOnlyChildren.this[string]</c>.</summary>
|
||||
public ReadOnlyComposition this[string compositionName] => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ZB.MOM.WW.ScadaBridge.Commons/ZB.MOM.WW.ScadaBridge.Commons.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,72 @@
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M3.1: parse + compile gate tests. The "representative real script" corpus is
|
||||
/// the PRIMARY guard that <see cref="ScriptCompileSurface"/> faithfully mirrors
|
||||
/// the runtime <c>ScriptGlobals</c> surface — if a member or signature drifts,
|
||||
/// the corpus stops binding and this test fails.
|
||||
/// </summary>
|
||||
public class RoslynScriptCompilerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParseDiagnostics_NonEmpty_ForSyntaxError()
|
||||
{
|
||||
Assert.NotEmpty(RoslynScriptCompiler.ParseDiagnostics("var x = ;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseDiagnostics_Empty_ForValidSyntax()
|
||||
{
|
||||
Assert.Empty(RoslynScriptCompiler.ParseDiagnostics("var x = 1;"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_NonEmpty_ForUndefinedSymbol()
|
||||
{
|
||||
var code = "var x = NoSuchThing.Foo();";
|
||||
Assert.NotEmpty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_Empty_ForRepresentativeRealScript()
|
||||
{
|
||||
const string code = """
|
||||
var temp = Attributes["Temperature"];
|
||||
Attributes["Setpoint"] = 42;
|
||||
var r = await ExternalSystem.Call("erp", "sync");
|
||||
var op = await Database.CachedWrite("hist", "INSERT ...");
|
||||
await Notify.To("ops").Send("subj", "msg");
|
||||
var shared = await Scripts.CallShared("Helper");
|
||||
var child = Children["Pump"].Attributes["Speed"];
|
||||
|
||||
// Widen coverage across the rest of the surface.
|
||||
var attr = await Instance.GetAttribute("Temperature");
|
||||
await Instance.SetAttribute("Setpoint", "43");
|
||||
var track = await Instance.Tracking.Status(op);
|
||||
var parentSpeed = Parent?.Attributes["Speed"];
|
||||
var alarmName = Alarm?.Name;
|
||||
var p = Parameters;
|
||||
var ct = CancellationToken;
|
||||
var status = await Notify.Status("notif-id");
|
||||
var cachedCall = await ExternalSystem.CachedCall("erp", "ping");
|
||||
var resolved = Attributes.Resolve("Temperature");
|
||||
var conn = await Database.Connection("hist");
|
||||
var scope = Scope;
|
||||
""";
|
||||
|
||||
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
Assert.Empty(diagnostics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_Empty_ForTriggerExpression()
|
||||
{
|
||||
const string expr =
|
||||
"Attributes[\"Temp\"] != null && (int)(Children[\"P\"].Attributes[\"S\"] ?? 0) > 5";
|
||||
|
||||
var diagnostics = RoslynScriptCompiler.Compile(expr, typeof(TriggerCompileSurface));
|
||||
Assert.Empty(diagnostics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// M3.1: adversarial + legitimate cases for the fused trust validator. Reject
|
||||
/// cases exercise the semantic pass (alias / using static / global::), the
|
||||
/// reflection-gateway hardening pass, and the namespace deny-list union; clean
|
||||
/// cases pin the allowed exceptions (Tasks, CancellationToken, Diagnostics
|
||||
/// other than Process).
|
||||
/// </summary>
|
||||
public class ScriptTrustValidatorTests
|
||||
{
|
||||
// ---- Reject (non-empty violations) --------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Rejects_SystemIo_Using()
|
||||
{
|
||||
var code = "using System.IO; var x = File.ReadAllText(\"/etc/passwd\");";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_SystemIo_GlobalQualified()
|
||||
{
|
||||
var code = "var x = global::System.IO.File.ReadAllText(\"x\");";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_SystemIo_Aliased()
|
||||
{
|
||||
var code = "using IO = System.IO; var f = IO.File.ReadAllText(\"x\");";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_SystemIo_UsingStatic()
|
||||
{
|
||||
var code = "using static System.IO.File; var s = ReadAllText(\"x\");";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_ReflectionGateway_OffPermittedType()
|
||||
{
|
||||
var code = "var t = typeof(string).Assembly.GetType(\"System.IO.File\");";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Dynamic_Keyword()
|
||||
{
|
||||
var code = "dynamic d = 5; d.Foo();";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Activator_CreateInstance()
|
||||
{
|
||||
var code = "var o = Activator.CreateInstance(typeof(string));";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_RuntimeInteropServices()
|
||||
{
|
||||
var code = "using System.Runtime.InteropServices; var h = Marshal.SizeOf<int>();";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_MicrosoftWin32()
|
||||
{
|
||||
var code = "using Microsoft.Win32; var k = Registry.LocalMachine;";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Threading_Thread_Sleep()
|
||||
{
|
||||
var code = "System.Threading.Thread.Sleep(10);";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_All_SystemNet()
|
||||
{
|
||||
var code = "System.Net.WebClient w = null;";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_ReflectionGateway_OffAllowedTaskType()
|
||||
{
|
||||
// Guards the reflection-first ordering: a gateway member hung off an
|
||||
// allowed System.Threading.Tasks type must still be rejected even though
|
||||
// the chain's namespace prefix is an allowed exception.
|
||||
var code = "var a = System.Threading.Tasks.Task.CompletedTask.GetType().Assembly;";
|
||||
Assert.NotEmpty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
// ---- Clean (empty violations) -------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Allows_TasksDelay()
|
||||
{
|
||||
var code = "await System.Threading.Tasks.Task.Delay(1);";
|
||||
Assert.Empty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allows_DiagnosticsStopwatch_NotProcess()
|
||||
{
|
||||
var code = "var sw = System.Diagnostics.Stopwatch.StartNew();";
|
||||
Assert.Empty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allows_CancellationTokenSource()
|
||||
{
|
||||
var code = "var c = new System.Threading.CancellationTokenSource();";
|
||||
Assert.Empty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Allows_LinqAndMath()
|
||||
{
|
||||
var code = "var n = System.Linq.Enumerable.Range(0,3).Sum(); var m = System.Math.Max(1,2);";
|
||||
Assert.Empty(ScriptTrustValidator.FindViolations(code));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user