fix(scripting): resolve High code-review finding (Core.Scripting-002)

The ForbiddenTypeAnalyzer syntax walker only inspected four node kinds
(ObjectCreation, Invocation-with-member-access, MemberAccess, bare
Identifier), so a forbidden type named through typeof, a generic type
argument, a cast, an is/as type pattern, default(T), an array-creation
element type, or an explicitly-typed local declaration produced no
examined node and bypassed the sandbox check.

Analyze now runs a second pass that resolves GetTypeInfo on every
TypeSyntax node and recursively unwraps array element types and generic
type arguments, so forbidden types nested at any depth are rejected at
compile. The original member/call node-kind switch is kept deliberately
narrow (rather than resolving GetSymbolInfo on every node) to avoid
flagging harmless inherited members such as typeof(int).Name, whose Name
property is declared by System.Reflection.MemberInfo. A span+type dedupe
keeps the two passes from emitting duplicate rejections.

Regression tests added in ScriptSandboxTests cover typeof, generic type
arguments, casts, default(T), is/as patterns, array element types, and
typed local declarations with forbidden types, plus over-block guards
asserting allowed generics and typeof still compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-22 06:08:08 -04:00
parent 8c7c605478
commit 7bb21c2aa2
3 changed files with 230 additions and 5 deletions

View File

@@ -100,6 +100,33 @@ public static class ForbiddenTypeAnalyzer
/// Returns empty list when the script is clean; non-empty list means the script
/// must be rejected at publish with the rejections surfaced to the operator.
/// </summary>
/// <remarks>
/// <para>
/// The walker has two passes per node. Pass (1) is the member / call surface:
/// <c>ObjectCreationExpressionSyntax</c>, <c>InvocationExpressionSyntax</c> with
/// a member-access target, <c>MemberAccessExpressionSyntax</c>, and bare
/// <c>IdentifierNameSyntax</c> are resolved via
/// <see cref="SemanticModel"/>.<c>GetSymbolInfo</c>. This catches static calls
/// (<c>System.IO.File.ReadAllText</c>) and constructors, and is deliberately
/// narrow: resolving <c>GetSymbolInfo</c> on <em>every</em> node would flag
/// harmless inherited members (e.g. <c>typeof(int).Name</c> resolves
/// <c>Name</c> to <c>System.Reflection.MemberInfo</c>, the base type that
/// declares it, even though the receiver type <c>System.Type</c> is allowed).
/// </para>
/// <para>
/// Pass (2) — the Core.Scripting-002 fix — resolves the <em>type</em> of every
/// <c>TypeSyntax</c> node via <c>GetTypeInfo</c>. The old walker only inspected
/// the four node kinds above, so a forbidden type named through
/// <c>typeof(System.IO.File)</c>, a generic argument
/// (<c>List&lt;System.IO.FileInfo&gt;</c>), a cast
/// (<c>(System.IO.Stream)null</c>), an <c>is</c> / <c>as</c> type pattern,
/// <c>default(System.Reflection.Assembly)</c>, an array-creation element type,
/// or an explicitly-typed local declaration produced no examined node and so
/// slipped through. Every <c>TypeSyntax</c> resolves to a concrete
/// <see cref="ITypeSymbol"/>; generic type arguments and array element types
/// are unwrapped recursively so a forbidden type nested at any depth is caught.
/// </para>
/// </remarks>
public static IReadOnlyList<ForbiddenTypeRejection> Analyze(Compilation compilation)
{
if (compilation is null) throw new ArgumentNullException(nameof(compilation));
@@ -111,6 +138,9 @@ public static class ForbiddenTypeAnalyzer
var root = tree.GetRoot();
foreach (var node in root.DescendantNodes())
{
// Pass (1) — member / call surface. Narrowly targeted at the node kinds
// that name a callable member or constructor, so inherited-member
// resolution does not produce false positives.
switch (node)
{
case ObjectCreationExpressionSyntax obj:
@@ -130,11 +160,43 @@ public static class ForbiddenTypeAnalyzer
CheckSymbol(semantic.GetSymbolInfo(id).Symbol, id.Span, rejections);
break;
}
// Pass (2) — type-reference surface (Core.Scripting-002). Every TypeSyntax
// resolves to the type it names, regardless of the syntactic form that
// introduced it (typeof operand, cast type, generic argument, default(T)
// operand, array element type, is/as pattern type, declared local type).
// Type arguments and array element types are walked recursively.
if (node is TypeSyntax)
CheckTypeSymbol(semantic.GetTypeInfo(node).Type, node.Span, rejections);
}
}
return rejections;
}
/// <summary>
/// Reject <paramref name="type"/> if it (or, recursively, any of its generic type
/// arguments / array element types) is forbidden. Walks the full type tree so a
/// forbidden type nested inside an allowed generic — e.g.
/// <c>List&lt;System.IO.FileInfo&gt;</c> — is still caught.
/// </summary>
private static void CheckTypeSymbol(ITypeSymbol? type, TextSpan span, List<ForbiddenTypeRejection> rejections)
{
if (type is null) return;
CheckSymbol(type, span, rejections);
switch (type)
{
case IArrayTypeSymbol array:
CheckTypeSymbol(array.ElementType, span, rejections);
break;
case INamedTypeSymbol named:
foreach (var arg in named.TypeArguments)
CheckTypeSymbol(arg, span, rejections);
break;
}
}
private static void CheckSymbol(ISymbol? symbol, TextSpan span, List<ForbiddenTypeRejection> rejections)
{
if (symbol is null) return;
@@ -149,6 +211,15 @@ public static class ForbiddenTypeAnalyzer
};
if (typeSymbol is null) return;
var typeName = typeSymbol.ToDisplayString();
// The broadened walk (Core.Scripting-002) resolves both GetSymbolInfo and
// GetTypeInfo on every node, so the same forbidden reference can be hit several
// times. Dedupe on span + type so the operator sees one rejection per offending
// reference, not a noisy pile of identical messages.
if (rejections.Any(r => r.Span == span && r.TypeName == typeName))
return;
var ns = typeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty;
foreach (var forbidden in ForbiddenNamespacePrefixes)
{
@@ -156,9 +227,9 @@ public static class ForbiddenTypeAnalyzer
{
rejections.Add(new ForbiddenTypeRejection(
Span: span,
TypeName: typeSymbol.ToDisplayString(),
TypeName: typeName,
Namespace: ns,
Message: $"Type '{typeSymbol.ToDisplayString()}' is in the forbidden namespace '{ns}'. " +
Message: $"Type '{typeName}' is in the forbidden namespace '{ns}'. " +
$"Scripts cannot reach {forbidden}* per Phase 7 sandbox rules."));
return;
}