# Component: Script Analysis

## Purpose

The Script Analysis component is the single authoritative source of truth for the ScadaBridge script trust model. It provides a unified forbidden-API deny-list, a fused semantic and syntactic trust validator, a Roslyn compile wrapper, and compile-only globals stubs used by the design-time deploy gate. All five call sites that enforce the script trust boundary — Template Engine, Site Runtime, Inbound API, Central UI, and Transport bundle import — delegate to this component rather than maintaining their own divergent implementations.

## Location

`src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/`

Referenced by: Template Engine, Site Runtime, Inbound API, Central UI, Transport.

## Responsibilities

- Define the canonical forbidden-API deny-list (`ScriptTrustPolicy`) as the single source of truth for all trust enforcement decisions across the system.
- Provide an authoritative forbidden-API verdict (`ScriptTrustValidator.FindViolations`) that fuses semantic symbol resolution with syntactic reflection-gateway hardening.
- Wrap Roslyn `CSharpScript` compilation (`RoslynScriptCompiler`) so callers share one implementation of compile + diagnostics extraction.
- Provide compile-only globals stubs (`ScriptCompileSurface`, `TriggerCompileSurface`) that mirror the real execution-time globals member-for-member, allowing the design-time deploy gate to do a real type-checking compile without depending on the execution-time projects.
- Own the shared process-wide metadata reference resolver (`CachingScriptMetadataResolver`) that every Roslyn script-compile surface in the system attaches to its `ScriptOptions`.

---

## Requirements

### REQ-SA-1: Trust Policy (`ScriptTrustPolicy`)

`ScriptTrustPolicy` is a static class (or record) that publishes the complete, authoritative forbidden-API policy used at every call site.

#### Forbidden scopes

The following namespace/type prefixes are forbidden in all scripts:

| Scope | Rationale |
|-------|-----------|
| `System.IO` | File system access — forbidden entirely |
| `System.Diagnostics.Process` | Process spawning — forbidden; `Stopwatch`, `Debug`, `Activity`, and other `System.Diagnostics` types are **allowed** |
| `System.Threading` | Raw thread manipulation — forbidden, with the exceptions below |
| `System.Reflection` | Reflection — forbidden entirely |
| `System.Net` | Raw network access — forbidden entirely (scripts must use `ExternalSystem.Call`) |
| `System.Runtime.InteropServices` | Native interop — forbidden entirely |
| `Microsoft.Win32` | Win32 API access — forbidden entirely |
| `System.Environment` | Whole type — forbidden entirely; exposes process control (`Exit`/`FailFast`), the host environment, and secrets via environment variables (e.g. `SCADABRIDGE_API_KEY`). Scripts that need a line break use `"\n"` instead of `Environment.NewLine`. |
| `System.GC` | Whole type — forbidden entirely; GC control (`Collect`, `KeepAlive`, memory-pressure knobs) has no legitimate script use. |
| `Microsoft.Data` | Concrete ADO.NET provider namespace (`Microsoft.Data.SqlClient`, …) — forbidden; closes the arbitrary-host `new SqlConnection("Server=attacker")` channel |
| `System.Data.SqlClient` | Concrete ADO.NET SQL Server provider — forbidden (see provider-namespace posture below) |
| `System.Data.Odbc` | Concrete ADO.NET ODBC provider — forbidden |
| `System.Data.OleDb` | Concrete ADO.NET OLE DB provider — forbidden |

##### `System.Data` provider-namespace posture

Only the **concrete ADO.NET provider namespaces** are forbidden — `Microsoft.Data` (which covers `Microsoft.Data.SqlClient`), `System.Data.SqlClient`, `System.Data.Odbc`, and `System.Data.OleDb`. The abstract `System.Data.Common` types (`DbConnection`, `DbCommand`, …) and `System.Data` broadly are **deliberately NOT forbidden**: scripts reach a database only through the sanctioned `Database` helper, whose surface exposes those abstract `System.Data.Common` types. Forbidding the concrete providers blocks a script from constructing its own connection to an arbitrary host (the `new SqlConnection(...)` channel) while leaving the sanctioned `Database` path intact.

#### Allowed exceptions within forbidden scopes

The following types are explicitly allowed despite falling within a forbidden namespace:

- `System.Threading.Tasks` (and all subtypes) — async/await support
- `System.Threading.CancellationToken` — cooperative cancellation
- `System.Threading.CancellationTokenSource` — cooperative cancellation

The scoping rationale: `System.Diagnostics.Process` is the dangerous type (spawns processes); `Stopwatch`, `Debug`, and `Activity` are harmless diagnostic utilities. Forbidding the whole `System.Diagnostics` namespace, as some earlier call sites did, was overly broad. In contrast, `System.Environment` and `System.GC` are forbidden as **whole types** because — unlike `System.Diagnostics` — they have no harmless members a script legitimately needs.

#### Reflection-gateway members

The following member names are blocked regardless of the receiver type, to prevent reflection-based bypasses such as `typeof(x).Assembly.GetType("System.IO.File")`:

`GetType`, `GetTypeInfo`, `Assembly`, `Module`, `CreateInstance`, `InvokeMember`, `GetMethod`, `GetMethods`, `GetConstructor`, `GetConstructors`, `GetField`, `GetFields`, `GetProperty`, `GetProperties`, `GetMember`, `GetMembers`, `GetRuntimeMethod`, `GetRuntimeMethods`, `MethodHandle`, `TypeHandle`, `GetTypes`, `EntryPoint`, `DeclaredMethods`, `DeclaredMembers`, `DeclaredConstructors`, `DynamicInvoke`.

`Invoke` is **deliberately excluded** from this list: the syntactic pass rejects a gateway member regardless of receiver, and `delegate.Invoke()` (`Func<>`/`Action<>` invocation) is a legitimate, common script pattern. The reflection late-bind `MethodInfo.Invoke` is already caught semantically via the `System.Reflection` scope, so excluding `Invoke` here avoids a false-positive blast radius without weakening the reflection deny-list.

#### Forbidden identifiers

The identifiers `dynamic` and `Activator` are forbidden at any scope, as they provide type-system escape hatches equivalent to reflection.

#### Default references and imports

`ScriptTrustPolicy` also publishes `DefaultReferences` (the canonical set of trusted-platform `MetadataReference` entries used when constructing the Roslyn script compilation context) and `DefaultImports` (the default `using` directives injected into every script). These are consumed by `RoslynScriptCompiler` and by the compile-only surfaces below.

---

### REQ-SA-2: Trust Validator (`ScriptTrustValidator`)

`ScriptTrustValidator.FindViolations(string code, IEnumerable<MetadataReference>? extraReferences = null)` is the **authoritative forbidden-API gate**. It returns a list of violation messages; an empty list means the script is clean.

#### Two-pass design

**Pass 1 — semantic symbol resolution (adapted from Site Runtime)**

- Builds a Roslyn compilation using `ScriptTrustPolicy.AnalysisReferences` (plus any `extraReferences`).
- For each identifier in the syntax tree, resolves the underlying symbol to its fully qualified containing namespace and type name.
- Flags any symbol whose containing namespace or type matches a forbidden scope in `ScriptTrustPolicy.ForbiddenScopes`, taking `AllowedExceptions` into account.
- Correctly handles aliases (`using X = System.IO.File`), `using static`, and `global::` prefixes — the resolved symbol is checked, not the spelling.
- The validation compilation applies the **same default namespace imports** (`ScriptTrustPolicy.DefaultImports` — `using System;`, `System.Collections.Generic`, `System.Linq`, `System.Threading.Tasks`) that `RoslynScriptCompiler` compiles the real script with. Without them the semantic pass under-resolves **bare** identifiers the running script sees fully bound — e.g. `Environment.Exit(0)` or `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden `System.Environment`/`System.GC` types only when `using System;` is in scope. Matching the runtime import set closes that blind spot; it can only make more identifiers resolve to their true (forbidden) namespace, never produce a false allow.
- Because the full reference set is loaded, this pass also catches a forbidden type accessed inside an otherwise-allowed namespace (e.g., bare `Process` after `using System.Diagnostics;`).

##### `AnalysisReferences` vs `DefaultReferences`

The two reference sets are deliberately distinct and must not be conflated:

- **`DefaultReferences`** — the **minimal, runtime-fidelity** set (built from `DefaultAssemblies`: CoreLib, LINQ, Math, the C# runtime binder, and the Commons API-surface assembly). It is consumed by `RoslynScriptCompiler` (the compile gate) and must mirror exactly what the site runtime compiles/executes against. It deliberately does **not** reference the forbidden-API anchor assemblies (`System.Diagnostics.Process.dll`, `System.Net.Sockets.dll`, …) so that a forbidden type remains an *undefined symbol* at compile time — the compile gate then independently rejects it, providing a second layer of defence. This set must stay minimal.
- **`AnalysisReferences`** — the **full-framework** set used *only* by `ScriptTrustValidator`'s Pass 1. It is built from `AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")` (the TPA list of the host) so that *every* type a script names resolves to its true namespace and is judged authoritatively. Enriching the analysis set can only *improve* detection (the verdict is by namespace/type, never a false allow), which is why the Central UI run gate may safely forward its full compilation reference surface as `extraReferences`.
- **TPA-fallback behaviour** — on a host that does not publish the TPA list (single-file, AOT, or trimmed deployment), `AnalysisReferences` falls back to `DefaultReferences` **enriched with `ForbiddenAnchorAssemblies`** (the assemblies that host the forbidden-API types). This keeps the documented forbidden anchors — notably bare `Process` inside the allowed `System.Diagnostics` namespace — resolvable, so the semantic pass stays authoritative even in the degraded mode. The fallback is **not silent**: `ScriptTrustPolicy.AnalysisReferencesDegraded` is set to `true` and a warning is emitted via `System.Diagnostics.Trace` so operators and tests can detect the weakened mode.

**Pass 2 — syntactic reflection-gateway and identifier hardening (adapted from Inbound API)**

- Walks the syntax tree for member-access expressions and simple name references.
- Flags any member name found in `ScriptTrustPolicy.ReflectionGatewayMembers`, regardless of receiver type.
- Flags any identifier token found in `ScriptTrustPolicy.ForbiddenIdentifiers` (`dynamic`, `Activator`).

Violations from both passes are merged and deduplicated before being returned.

#### Design notes

- `FindViolations` needs no globals type; it operates solely on the script text and the trusted-platform reference set.
- The function is stateless and thread-safe — callers share a single instance or call it as a static method.
- A violation does not abort compilation; callers may choose to report violations and continue, or treat any violation as a hard reject.

---

### REQ-SA-3: Roslyn Compile Wrapper (`RoslynScriptCompiler`)

`RoslynScriptCompiler` wraps `CSharpScript` to give callers a single implementation of compile + diagnostics extraction.

#### `Compile(string code, Type? globalsType = null, IEnumerable<MetadataReference>? extraReferences = null, IEnumerable<string>? extraImports = null)`

- Creates a `CSharpScript` with the given code, `globalsType`, references (defaults from `ScriptTrustPolicy.DefaultReferences` plus `extraReferences`), and imports (defaults from `ScriptTrustPolicy.DefaultImports` plus `extraImports`).
- Calls `.Compile()` and returns the resulting `Diagnostic[]` filtered to **error-severity diagnostics only**. This is a compile *gate*: a warning must not block a deploy, so only errors (undefined symbols, type mismatches) are surfaced to callers as gate failures.
- Each caller passes its own `globalsType` — `ScriptCompileSurface` for the design-time deploy gate, the real `ScriptGlobals` for Site Runtime execution, `null` for pure syntax checks.

#### `ParseDiagnostics(string code)`

- Parses the script text using Roslyn's `CSharpSyntaxTree.ParseText` and returns **error-severity** syntax-level diagnostics only (consistent with `Compile` — warnings do not fail the gate).
- No compilation is performed — useful for fast syntax checks where no globals type is available.

---

### REQ-SA-4: Compile-Only Globals Stubs

The deploy gate in Template Engine must do a real type-checking compile (to catch undefined-symbol and type errors) but cannot depend on the execution-time projects (Site Runtime, Inbound API) that own the real globals. Two compile-only stubs solve this:

#### `ScriptCompileSurface`

Mirrors `ScriptGlobals` member-for-member (same public property names, same return types, same method signatures) but with no implementation bodies. All properties return `default` and all methods return `default` or `Task.CompletedTask`. Depends only on `Commons.Types` — no Akka.NET, no external system clients.

Used by the Template Engine deploy gate:
```csharp
var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
```
This allows the compile to bind `Attributes["name"]`, `Notify.To("x").Send(...)`, `ExternalSystem.Call(...)`, `Alarms.CurrentAsync()`, and similar API calls against real types, catching undefined-symbol and type-mismatch errors before deployment.

Where an accessor returns a domain type rather than a primitive, the stub returns the **same** type the runtime returns (not a compile-only stand-in) — `Alarms.CurrentAsync()` yields `IReadOnlyList<ScriptAlarm>` on both surfaces, so a script reading `a.Severity` / `a.AckTime` binds identically at the gate and at the site. This works because those types live in Commons, which is already in `DefaultAssemblies`.

**Trust-model note:** the script trust boundary is a **deny-list over API roots**, not an allow-list of context members, so adding a globals member such as `Alarms` requires no `ScriptTrustPolicy` change. What must hold is that the member's return type resolves in a *permitted* namespace — pinned by a test asserting no entry in `ForbiddenScopes` prefixes the Commons script-surface namespace.

#### `TriggerCompileSurface`

Mirrors `TriggerExpressionGlobals` in the same way. Used by `ValidationService.CheckExpressionSyntax` in the Template Engine for conditional and expression trigger validation.

`CheckExpressionSyntax` memoises its verdict in the Template Engine's process-wide `ScriptCompileVerdictCache`, whose key is the pair **(globals surface, SHA-256 of the code)** — not the code alone. The surface discriminator is load-bearing: a trigger expression vetted against `TriggerCompileSurface` is **not** interchangeable with a `ScriptCompileSurface` script-body verdict (different globals resolve different identifiers), so a code-only key could return a stale "clean" for code never compiled against the caller's surface. Keying by surface makes that cross-surface verdict reuse structurally impossible.

**Eviction is segmented, never wholesale (WP2.5).** The cache is bounded at two
generations of 2048 entries. New verdicts land in the *hot* generation; a hit in
*cold* is promoted back into hot; on overflow hot becomes the new cold and only the
old cold — the half nothing has touched for a full generation — is dropped.

The bound must never be enforced by clearing the cache outright. That reads as
harmless ("a verdict is cheap to recompute") but is not: a recompute is a fresh
Roslyn compile, and every script compile loads an assembly through a
**non-collectible `InteractiveAssemblyLoader`**. Bounding that leak is the entire
reason this cache exists, so dropping every hot entry at the 4096th distinct script
would re-open it for the whole working set at once. Segmenting retains anything in
active use across an eviction while keeping the same ceiling.

#### Parity guard

A reflection-based parity test in `SiteRuntime.Tests` compares the public member names on `ScriptCompileSurface` against `ScriptGlobals` (and `TriggerCompileSurface` against `TriggerExpressionGlobals`). Any drift between the stub and the real globals causes this test to fail, ensuring the stubs cannot silently fall out of sync.

There is a **third** hand-maintained mirror of the runtime globals: the Central UI Test-Run host `SandboxScriptHost` (see REQ-SA-5 / Interactions). Because Central UI deliberately does not reference Site Runtime, it cannot share the reflection-based parity test above; instead it is guarded by representative-script "diagnose clean" tests in `CentralUI.Tests` (one per non-trivial surface — e.g. the `Attributes.WriteBatchAndWaitAsync` / `WaitAsync` / `WaitForAsync` batch-write-and-wait helpers, the inbound `Database`/`WaitForAttribute` helpers, and the `Notify` outbox shape). A member that drifts out of `SandboxScriptHost` does **not** fail the deploy gate (which compiles against `ScriptCompileSurface`) — it surfaces only as an in-editor `CS1061` false error against otherwise-valid scripts, so these compile-clean tests are the safety net for that surface.

---

### REQ-SA-5: Consumer Delegation

All five call sites that previously maintained their own script trust enforcement (or, for Transport, had none) now delegate to this component. The key behavioral changes per consumer:

| Consumer | Before | After |
|----------|--------|-------|
| **Template Engine** `ScriptCompiler.TryCompile` | Substring scan + brace-balance (advisory, bypassable) | `FindViolations` + real `Compile` against `ScriptCompileSurface` — authoritative gate |
| **Template Engine** `ValidationService.CheckExpressionSyntax` | Regex / brace scan | `FindViolations` + `Compile` against `TriggerCompileSurface` |
| **Site Runtime** `ScriptCompilationService.ValidateTrustModel` | Semantic resolver, no reflection-gateway hardening | Delegates to `FindViolations`; retains `CSharpScript.Compile` against real `ScriptGlobals` for execution |
| **Inbound API** `ForbiddenApiChecker.FindViolations` | Syntactic walker, forbade all `System.Diagnostics` | Thin shim delegating to `ScriptTrustValidator.FindViolations`; `System.Diagnostics` loosened to `.Process`-only |
| **Central UI** `ScriptAnalysisService` | Semantic + full compile, lenient threading | Delegates forbidden-API verdict and sources editor-marker deny-list from `ScriptTrustPolicy`; retains Test-Run execution host |
| **Transport** `BundleImporter.RunSemanticValidationAsync` / `DetectBlockersAsync` | No trust gate — a bundle's forbidden-API scripts imported clean, deferred to runtime (fifth write path) | Runs `ScriptTrustValidator.FindViolations` over every non-Skip template / shared / ApiMethod body, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** at import review — hard error (apply) / Blocker row (preview) for all kinds |

The static enforcement is **defence-in-depth**, not a true runtime sandbox. Scripts execute in-process; the denied API list prevents obvious escapes at compile time but does not provide the isolation guarantees of an out-of-process sandbox or a restricted `AssemblyLoadContext`. This caveat applies to all consumers.

---

### REQ-SA-6: Shared Metadata Reference Resolver (`CachingScriptMetadataResolver`)

`CachingScriptMetadataResolver` is a process-wide memoizing decorator over Roslyn's default script metadata resolver (`ScriptOptions.Default.MetadataResolver`), owned by this component because it is a property of *script compilation*, not of any one consumer. `CachingScriptMetadataResolver.Instance` is the single shared instance — the cache lives on the instance, so a second instance is a second (empty) cache.

#### The invariant

**Every Roslyn script-compile surface in the system MUST attach the shared instance** via `ScriptOptions.WithMetadataResolver(CachingScriptMetadataResolver.Instance)`. There are four such surfaces today: Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and this component's own `RoslynScriptCompiler`. A surface that omits it — including a new fifth surface — reintroduces a per-compile native-memory leak, and does so silently.

#### Why

The `ScriptOptions` on each surface carry only the direct API-surface references; every `script.Compile()` binds their **transitive closure**, and each transitively-referenced assembly is resolved through the options' `MetadataReferenceResolver`. Roslyn's default resolver has **no cross-compilation cache**: each resolution calls `MetadataReference.CreateFromFile`, which eagerly copies the whole assembly into native memory (`AssemblyMetadata` → `PEReader` → `NativeHeapMemoryBlock`). So a 5-assembly explicit set re-resolves its ~74-assembly closure afresh on *every* compile, and those references are pinned for the process lifetime by the compile caches that hold the compiled scripts. Confirmed live (2026-08-12 gcdumps): 21 site scripts held 6,640 metadata objects, 6 central inbound methods 2,699. With the decorator, each distinct assembly is materialized **once per process** regardless of compile count.

This is the second and dominant half of the leak whose first half — per-compile `ScriptOptions` construction — was fixed by making the options static (see Site Runtime).

#### Trust-model neutrality

The decorator changes **no resolution result**, only object identity: it returns exactly what the inner (undecorated) resolver returned for the same arguments, so it can never resolve anything the undecorated options would not have resolved. `DefaultReferences` stays minimal and the compile gate keeps seeing exactly what it saw before — this fix deliberately does not widen any explicit reference set, precisely to avoid semantic drift in a security gate.

#### Cache-correctness assumptions

- `ResolveMissingAssembly` keys on `AssemblyIdentity.GetDisplayName()` (case-insensitive, matching .NET simple-name binding) and deliberately ignores the requesting `definition`, whose directory is a search path in the inner resolver. Safe here: every node runs from a single publish directory plus the shared framework, so identity → path is stable process-wide. A `null` result is cached too — "not found" is a legitimate, sticky answer.
- `ResolveReference` keys on the full `(reference, baseFilePath, properties)` tuple, case-**sensitively**, because `baseFilePath` is a filesystem path on the Linux containers these nodes run in.
- A `GetOrAdd` factory race can mint one duplicate — bounded and benign. Entries are never disposed; the cache is bounded by the distinct assemblies on disk, the same order as the static `AnalysisReferences`.

---

## Dependencies

- **Commons**: Shared types referenced by `ScriptCompileSurface` and `TriggerCompileSurface` (e.g., `DataType`, attribute access types).
- **Microsoft.CodeAnalysis.CSharp.Scripting**: Roslyn scripting APIs used by `RoslynScriptCompiler` and `ScriptTrustValidator`.
- **Microsoft.CodeAnalysis.CSharp.Workspaces**: Roslyn workspace/syntax APIs used by `ScriptTrustValidator`.

No dependency on Akka.NET, ASP.NET Core, Entity Framework, or any other ScadaBridge component above Commons.

## Interactions

- **Template Engine (#1)**: Consumes `ScriptTrustValidator.FindViolations`, `RoslynScriptCompiler.Compile`, `ScriptCompileSurface`, and `TriggerCompileSurface` in the design-time deploy gate (`ScriptCompiler.TryCompile`) and expression syntax validator (`ValidationService.CheckExpressionSyntax`).
- **Site Runtime (#3)**: `ScriptCompilationService.ValidateTrustModel` delegates the trust verdict to `ScriptTrustValidator.FindViolations`; retains its own `CSharpScript.Compile` against the real `ScriptGlobals` for execution-time compilation.
- **Inbound API (#14)**: `ForbiddenApiChecker.FindViolations` is a thin shim over `ScriptTrustValidator.FindViolations`.
- **Central UI (#9)**: `ScriptAnalysisService` delegates the run-gate forbidden-API verdict and sources the editor-marker deny-list from `ScriptTrustPolicy`; retains the Test-Run execution host (`SandboxScriptHost`).
- **All script-compile surfaces**: Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and this component's own `RoslynScriptCompiler` each attach `CachingScriptMetadataResolver.Instance` to their `ScriptOptions` (REQ-SA-6).
- **Transport (#24)**: `BundleImporter` runs `ScriptTrustValidator.FindViolations` over every non-Skip template, shared, and ApiMethod script body, template script + alarm Expression-trigger bodies, **and instance alarm-override trigger expressions** (`InstanceAlarmOverrideDto.TriggerConfigurationOverride`) during import validation (`RunSemanticValidationAsync` Pass 0) and preview (`DetectBlockersAsync`), rejecting forbidden-API scripts at import review rather than at runtime. Trust violations are hard errors for all script kinds (unlike the false-positive-prone name-resolution heuristic, whose template-script findings are advisory).
