9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
425 lines
19 KiB
C#
425 lines
19 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
///
|
|
/// <para>
|
|
/// A forbidden type reference inside <c>nameof(...)</c> (e.g.
|
|
/// <c>nameof(System.IO.File)</c>) is deliberately flagged: this is
|
|
/// conservative/fail-safe — <c>nameof</c> is rare in scripts and a script has
|
|
/// no business naming a forbidden type even there, so we prefer fail-safe over
|
|
/// adding special-case suppression logic to a security trust 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 <b>widen symbol resolution</b>
|
|
/// for the semantic pass — e.g. the compile-surface globals assembly so a
|
|
/// script referencing the API surface resolves cleanly. Extra references can
|
|
/// ONLY improve resolution; they can NEVER whitelist a forbidden API. The
|
|
/// verdict is by resolved namespace/type against
|
|
/// <see cref="ScriptTrustPolicy.ForbiddenScopes"/>, so passing more
|
|
/// references — even the forbidden-API assemblies themselves — can only make
|
|
/// the verdict more accurate (more types resolve to their true namespace and
|
|
/// are judged), never produce a false allow. Callers therefore need not (and
|
|
/// cannot, for safety purposes) curate this set; the Central UI run gate
|
|
/// forwards its full compilation reference surface here precisely because
|
|
/// doing so is safe.
|
|
/// </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)
|
|
=> FindViolations(code, ScriptTrustPolicy.AnalysisReferences, extraReferences);
|
|
|
|
/// <summary>
|
|
/// Overload that runs the trust analysis against an explicit base reference
|
|
/// set instead of <see cref="ScriptTrustPolicy.AnalysisReferences"/>. The
|
|
/// public entry point above always uses the full analysis set; this overload
|
|
/// exists so tests can pin behaviour against the minimal TPA-fallback set
|
|
/// (<see cref="ScriptTrustPolicy.BuildMinimalFallbackReferences"/>) and prove
|
|
/// the degraded mode still catches the documented forbidden anchors.
|
|
/// The two passes are otherwise identical.
|
|
/// </summary>
|
|
/// <param name="code">The C# script source to analyse.</param>
|
|
/// <param name="baseReferences">The base reference set Pass 1 resolves against.</param>
|
|
/// <param name="extraReferences">Optional additional references that only widen resolution.</param>
|
|
/// <returns>A list of trust-model violation messages; empty if the script is clean.</returns>
|
|
public static IReadOnlyList<string> FindViolations(
|
|
string code,
|
|
IReadOnlyList<MetadataReference> baseReferences,
|
|
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) ----
|
|
// Resolve against the supplied baseReferences so EVERY type a script names
|
|
// resolves and is judged by its true namespace — closing the
|
|
// forbidden-type-in-allowed-namespace blind spot (e.g. a bare
|
|
// System.Diagnostics.Process via `using System.Diagnostics;`). The public
|
|
// entry point passes the full trusted-platform reference set; a caller on the
|
|
// degraded/test path may instead pass the minimal anchor-enriched fallback
|
|
// (BuildMinimalFallbackReferences()).
|
|
var references = baseReferences.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") must NOT be
|
|
// reported — but we STILL DESCEND into its children so a forbidden
|
|
// type nested inside (e.g. a generic argument under an allowed
|
|
// System.Threading.Tasks.TaskCompletionSource<System.Threading.Mutex>)
|
|
// is independently visited and flagged by this pass. Suppress the
|
|
// outer report, do not stop the walk. (Pass 2 is self-sufficient and
|
|
// does not rely on the semantic pass to catch nested forbidden refs.)
|
|
if (IsAllowedExceptionPrefix(text))
|
|
{
|
|
base.VisitQualifiedName(node);
|
|
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)
|
|
// must NOT be reported — the bare forbidden-namespace qualifier of an
|
|
// allowed type (e.g. the "System.Threading" inside
|
|
// "System.Threading.Tasks.Task.Delay") is a false positive. But we
|
|
// STILL DESCEND into its children so a forbidden reference nested
|
|
// inside (e.g. a System.IO.File access in an allowed
|
|
// System.Threading.Tasks.Task.Run(...) lambda body) is independently
|
|
// visited and flagged by this pass. Suppress the outer report, do not
|
|
// stop the walk. (Pass 2 is self-sufficient — it does not rely on the
|
|
// semantic pass to catch nested forbidden refs.)
|
|
if (IsAllowedExceptionPrefix(text))
|
|
{
|
|
base.VisitMemberAccessExpression(node);
|
|
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. ('dynamic' already returned above.)
|
|
if (ScriptTrustPolicy.ForbiddenIdentifiers.Contains(text))
|
|
{
|
|
_violations.Add($"forbidden reflection type reference '{text}'");
|
|
return;
|
|
}
|
|
|
|
base.VisitIdentifierName(node);
|
|
}
|
|
}
|
|
}
|