fix(scripting): block dangerous System types in the script sandbox (Core.Scripting-001)

ForbiddenTypeAnalyzer used only a namespace-prefix deny-list. System.Environment,
System.AppDomain, System.GC and System.Activator live directly in the System
namespace, which must stay allowed for primitives (Math, String, ...), so they
were never caught — an operator-authored predicate could call
System.Environment.Exit(0) and terminate the in-process OPC UA server.

Add a type-granular deny-list (ForbiddenFullTypeNames) checked by
fully-qualified type name after the namespace-prefix check; legitimate System
types are unaffected.

Regression tests assert scripts referencing Environment/AppDomain/GC/Activator
are rejected at analysis time. Core.Scripting suite: 68/68 pass.

Resolves code-review finding Core.Scripting-001 (Critical).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 05:54:08 -04:00
parent 973730d0eb
commit cfb9ff1032
4 changed files with 162 additions and 29 deletions

View File

@@ -20,10 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// Deny-list is the authoritative Phase 7 plan decision #6 set:
/// <c>System.IO</c>, <c>System.Net</c>, <c>System.Diagnostics.Process</c>,
/// <c>System.Reflection</c>, <c>System.Threading.Thread</c>,
/// <c>System.Runtime.InteropServices</c>. <c>System.Environment</c> (for process
/// env-var read) is explicitly left allowed — it's read-only process state, doesn't
/// persist outside, and the test file pins this compromise so tightening later is
/// a deliberate plan decision.
/// <c>System.Runtime.InteropServices</c>.
/// </para>
/// <para>
/// Deny-list prefix match. <c>System.Net</c> catches <c>System.Net.Http</c>,
@@ -32,6 +29,21 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// operator audience authors it through a helper the plan team adds as part of
/// the <see cref="ScriptContext"/> surface, not by unlocking the namespace.
/// </para>
/// <para>
/// A namespace-prefix deny-list is necessary but not sufficient: dangerous types
/// such as <c>System.Environment</c>, <c>System.AppDomain</c>, <c>System.GC</c>,
/// and <c>System.Activator</c> live <em>directly</em> in the <c>System</c>
/// namespace inside <c>System.Private.CoreLib</c> — the same allow-listed assembly
/// that supplies primitives (<c>int</c>, <c>string</c>, <c>Math</c>). They cannot
/// be blocked by namespace because <c>System</c> itself must stay allowed. They
/// are therefore denied <em>type-granularly</em> via
/// <see cref="ForbiddenFullTypeNames"/>. <c>Environment.Exit</c> /
/// <c>Environment.FailFast</c> kill the in-process OPC UA server outright;
/// <c>Activator.CreateInstance</c> is a reflection-equivalent escape; <c>GC</c>
/// and <c>AppDomain</c> expose process-wide control. Legitimate <c>System</c>
/// types (<c>Math</c>, <c>String</c>, <c>Convert</c>, <c>DateTime</c>, …) are not
/// on the list and stay usable. (Core.Scripting-001.)
/// </para>
/// </remarks>
public static class ForbiddenTypeAnalyzer
{
@@ -53,6 +65,36 @@ public static class ForbiddenTypeAnalyzer
"Microsoft.Win32", // registry
];
/// <summary>
/// Fully-qualified type names scripts are NOT allowed to reference, regardless of
/// namespace. These types live directly in the allow-listed <c>System</c>
/// namespace (in <c>System.Private.CoreLib</c>), so a namespace-prefix rule cannot
/// reach them without also blocking primitives. Matched by exact fully-qualified
/// name against the resolved <em>type</em> symbol — every member of the type
/// (including read-only ones) is therefore rejected.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item><c>System.Environment</c> — <c>Exit</c> / <c>FailFast</c> terminate
/// the host process; the whole type is denied (the read members have no
/// legitimate SCADA-predicate use either).</item>
/// <item><c>System.AppDomain</c> — process-wide assembly-load /
/// unhandled-exception control.</item>
/// <item><c>System.GC</c> — <c>Collect</c> / <c>AddMemoryPressure</c> perturb
/// the process memory subsystem.</item>
/// <item><c>System.Activator</c> — <c>CreateInstance</c> is a
/// reflection-equivalent escape that constructs a forbidden type by name
/// without ever naming it syntactically.</item>
/// </list>
/// </remarks>
public static readonly IReadOnlyList<string> ForbiddenFullTypeNames =
[
"System.Environment",
"System.AppDomain",
"System.GC",
"System.Activator",
];
/// <summary>
/// Scan the <paramref name="compilation"/> for references to forbidden types.
/// Returns empty list when the script is clean; non-empty list means the script
@@ -121,6 +163,29 @@ public static class ForbiddenTypeAnalyzer
return;
}
}
// Type-granular deny-list — dangerous types that live in the allow-listed
// System namespace and so cannot be caught by ForbiddenNamespacePrefixes
// (Core.Scripting-001). Matched on the full type name; OriginalDefinition
// unwraps any generic construction before naming.
var fullTypeName = typeSymbol.OriginalDefinition.ToDisplayString(
SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(
SymbolDisplayGlobalNamespaceStyle.Omitted));
foreach (var forbiddenType in ForbiddenFullTypeNames)
{
if (fullTypeName == forbiddenType)
{
rejections.Add(new ForbiddenTypeRejection(
Span: span,
TypeName: typeSymbol.ToDisplayString(),
Namespace: ns,
Message: $"Type '{forbiddenType}' is on the Phase 7 sandbox forbidden-type " +
$"deny-list. Scripts cannot reach process-control types " +
$"(Environment / AppDomain / GC / Activator) even though they " +
$"live in the allowed 'System' namespace."));
return;
}
}
}
}