feat(scriptanalysis): M3.1 shared trust validator + compiler + compile surfaces + tests

This commit is contained in:
Joseph Doherty
2026-06-16 19:18:39 -04:00
parent 0cc8642cfa
commit 4f2b17ce6d
10 changed files with 1100 additions and 0 deletions
@@ -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);
}
}
}