Full per-module re-review of the 16 stale modules (last seen1eb6e97/ 2026-05-28) plus first-ever reviews of KpiHistory (#26) and ScriptAnalysis (#25), at HEAD4307c381. 67 new findings (0 Critical, 6 High, 27 Medium, 34 Low). Remediation in commitfd618cf1closed 5 of the 6 Highs and ~33 Medium/Low; the rest are Deferred/Won't Fix with rationale. Remaining pending (4) are all InboundAPI's Database-helper findings (IA-026 High .. IA-029), left to the active feat/ipsen-movein effort per owner decision. Highlights: caught a central-only-delivery security drift (SMTP creds broadcast to sites — DM-025/SR-031), a never-committed 'Resolved' fix (SiteEventLogging-016 → -024), an unguarded KPI recorder tick (KH-001), a trust-analyzer fallback weakening (SA-001), and a native-alarm subscribe-path leak (DCL-023). ScriptAnalysis verdict: trust boundary is semantically sound (symbol-based) in the production cluster config. README regenerated; regen-readme.py --check passes (4 pending / 567 total).
24 KiB
Code Review — ScriptAnalysis
| Field | Value |
|---|---|
| Module | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis |
| Design doc | docs/requirements/Component-ScriptAnalysis.md |
| Status | Reviewed |
| Last reviewed | 2026-06-20 |
| Reviewer | claude-agent |
| Commit reviewed | 4307c381 |
| Open findings | 0 |
Summary
This is the first formal review of the ScriptAnalysis component (#25), the single
authoritative source of truth for the ScadaBridge script trust boundary. The module
is small (5 source files, ~700 lines) and well-documented, and its core design is
sound: the trust verdict is genuinely semantic (Roslyn symbol resolution in
Pass 1), not a string/regex deny-list, so the classic spelling-based evasions —
aliases, using static, global::, partial-namespace qualification — are correctly
defeated by resolving the symbol rather than the text. The reflection-gateway
hardening (Pass 2) closes the typeof(x).Assembly.GetType("System.IO.File") class of
bypass that a namespace-only deny-list cannot see. All four documented consumers
(Template Engine, Site Runtime, Inbound API, Central UI) genuinely delegate to
ScriptTrustValidator.FindViolations and hard-reject on a non-empty result; none
re-implements or relaxes the policy. A reflection-based parity guard for the
compile-only surfaces exists in SiteRuntime.Tests. No working trust-boundary
bypass was found at HEAD against the production (cluster-hosted) configuration.
That said, the review surfaced several real issues. The highest-impact one is a
silent degradation of the semantic pass when TRUSTED_PLATFORM_ASSEMBLIES is
unavailable (single-file/AOT/trimmed host): AnalysisReferences falls back to the
5-assembly minimal set, after which a bare forbidden type inside an allowed
namespace (the documented Process via using System.Diagnostics; case) no longer
resolves and slips Pass 1 entirely — and Pass 2 never flags bare identifiers, only
dotted spellings (ScriptAnalysis-001). This is the only finding that can weaken the
verdict, and it is narrow (the production hosts carry a TPA list, and the downstream
real Compile gate catches it for two of the four consumers), so it is rated High
rather than Critical. The remaining findings are robustness/correctness/doc issues:
the semantic pass swallows nothing but the unresolved-symbol fallback is namespace-
spelling-dependent (Medium); nameof(...) over-flags legitimate non-forbidden uses
inconsistently (Low, documented-intentional but worth pinning); ParseDiagnostics/
Compile drop warnings despite the doc promising "errors and warnings"; there is no
caching/throttling of the Roslyn compilations (performance); the extraReferences
XML-doc on FindViolations is contradicted by the Central UI consumer; and the
adversarial test corpus, though good, has gaps (no extension-method, no
TPA-fallback, no file-scoped/unsafe, no verbatim/Unicode-escape identifier
cases). The trust boundary is semantically sound in production; the open items
harden the edges and align the spec.
Checklist coverage
| # | Category | Examined | Notes |
|---|---|---|---|
| 1 | Correctness & logic bugs | ☑ | nameof over-flags inconsistently (ScriptAnalysis-005); GetSyntacticScopes only returns text for dotted names so a bare unresolved forbidden identifier yields no scope (root of ScriptAnalysis-001/002). |
| 2 | Akka.NET conventions | ☑ | No issues found — module has no actors, no Akka dependency (by design; csproj references only Roslyn + Commons). Stateless static APIs, thread-safe. |
| 3 | Concurrency & thread safety | ☑ | No issues found — all public surfaces are static/stateless; the shared static readonly policy collections are read-only after init; HardeningWalker is per-call. AnalysisReferences eager-built once at type init. |
| 4 | Error handling & resilience | ☑ | RoslynScriptCompiler.Compile converts exceptions into a fail-the-gate error entry (fail-safe). BuildAnalysisReferences swallows per-assembly read errors and silently falls back to the minimal set — the silent fallback is the resilience gap (ScriptAnalysis-001). |
| 5 | Security | ☑ | Semantic (symbol-based) verdict — no spelling bypass found in production. One conditional degradation (TPA-absent host) weakens Pass 1 to spelling-dependent (ScriptAnalysis-001); extraReferences can never cause a false allow (verified). Defence-in-depth only, as documented — not a runtime sandbox (ScriptAnalysis-008). |
| 6 | Performance & resource management | ☑ | No compilation caching/throttling; every gate call re-parses, rebuilds a compilation, and re-runs Roslyn — FindViolations builds a fresh CSharpCompilation against the full framework reference set on every call (ScriptAnalysis-006). |
| 7 | Design-document adherence | ☑ | ParseDiagnostics/Compile drop warnings though doc says "errors and warnings" (ScriptAnalysis-004); AnalysisReferences (full-framework set) is not described in REQ-SA-2, which says DefaultReferences (ScriptAnalysis-007). |
| 8 | Code organization & conventions | ☑ | No issues found — POCO/static layout is clean, namespace correct, depends only on Commons + Roslyn. ScriptCompileSurface lives in ScriptAnalysis (not Commons) — acceptable as it mirrors a runtime surface and is consumed here. |
| 9 | Testing coverage | ☑ | Good adversarial reject/allow corpus, but missing: TPA-fallback degradation, extension methods, verbatim/Unicode-escape identifiers, unsafe/pointer, comment/trivia, and AnalysisReferences/exception paths (ScriptAnalysis-003). |
| 10 | Documentation & comments | ☑ | FindViolations XML-doc claim "Forbidden references are NOT added here" is contradicted by the Central UI consumer forwarding compilation.References (ScriptAnalysis-007, doc half). Otherwise thorough and accurate. |
Findings
ScriptAnalysis-001 — Semantic trust pass silently degrades to spelling-dependent when TRUSTED_PLATFORM_ASSEMBLIES is unavailable
| Severity | High |
| Category | Security |
| Status | Resolved |
| Location | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustPolicy.cs:146-184; interacts with ScriptTrustValidator.cs:111-136, :195-213 |
Description
AnalysisReferences is the reference set that lets Pass 1 (the semantic symbol pass —
the authoritative guard) resolve every type a script names to its true namespace.
It is built from AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"). When that value
is absent — a single-file deployment, an AOT/trimmed host, or any runtime that does
not publish the TPA list — BuildAnalysisReferences falls back to the 5-assembly
DefaultReferences set (ScriptTrustPolicy.cs:183) silently (the per-assembly
catch { } at :165/:178 and the Count > 0 fallback at :183 emit nothing).
After that fallback, a bare forbidden type that lives inside an allowed namespace
no longer resolves. The policy's own documented example is exactly this case:
using System.Diagnostics; var p = Process.Start("x");. System.Diagnostics is an
allowed namespace (so VisitUsingDirective does not flag the import), and bare
Process is an IdentifierName. With Process.dll absent from the minimal
reference set, model.GetSymbolInfo returns no symbol, so Pass 1 falls to
GetSyntacticScopes, which returns an empty scope list for a bare (dotless)
identifier (ScriptTrustValidator.cs:210-212). Pass 2's HardeningWalker only flags
forbidden dotted names and forbidden using imports — never a bare identifier
token (VisitIdentifierName, :373-395, only checks dynamic/Activator). The
forbidden Process.Start therefore slips the trust validator entirely. The same
degradation reopens the broader "forbidden type in an allowed namespace reached as a
bare identifier" class that the full-framework reference set was specifically added
(commit 06975720, M3.6) to close.
This is rated High not Critical because: (a) the production hosts (Akka.NET cluster,
docker topology) run on a normal framework-dependent runtime that publishes the TPA
list, so the full set is loaded; and (b) for two of the four consumers (Template
Engine, Site Runtime) the downstream real Compile gate against a curated reference
set independently rejects an undefined Process symbol. But Inbound API and the
Central UI run gate rely on FindViolations for the reflection/namespace verdict,
and a deployment-mode change that drops the TPA list would silently weaken the
boundary with no diagnostic.
Recommendation
Make the degradation loud and fail-safe: when the TPA list is unavailable, log a
warning (or expose a diagnostic flag the consumers can surface), and — more
importantly — add the BCL assemblies that host the forbidden-type-in-allowed-namespace
cases (at minimum System.Diagnostics.Process) to DefaultAssemblies so they resolve
even in the minimal fallback. Alternatively, extend Pass 2 to flag bare identifiers
whose name matches the simple name of a known forbidden type (e.g. Process,
WebClient), independent of symbol resolution, so the verdict never depends solely on
the reference set being complete.
Resolution
Resolved 2026-06-20 (commit fd618cf1): the TPA-absent fallback now folds the
assemblies hosting the forbidden anchor types (Process, Socket, File, Thread, Assembly,
Marshal) into the minimal validation reference set so a bare forbidden type still
resolves and is flagged; an AnalysisReferencesDegraded flag + a Trace.TraceWarning
make the degraded mode observable. CRITICAL: anchors are added ONLY to the validation
AnalysisReferences, never to DefaultReferences — keeping the compile gate from being
able to bind Process. Verified by a TPA-fallback test that a bare Process.Start is
still rejected.
ScriptAnalysis-002 — Pass 2 (syntactic hardening) is spelling-dependent and cannot catch a bare forbidden identifier on its own
| Severity | Medium |
| Category | Security |
| Status | Deferred |
| Location | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs:289-395; :195-213 |
Description
Pass 2 (HardeningWalker) is purely textual: VisitUsingDirective flags forbidden
using imports (:289-295), VisitQualifiedName/VisitMemberAccessExpression flag
forbidden dotted names (:298-369), and VisitIdentifierName flags only the bare
identifiers dynamic/Activator (:373-395). It cannot independently flag any other
bare forbidden identifier — it relies entirely on Pass 1's semantic resolution for
that. The two passes are therefore not the "two independent guards" the design implies
for the bare-identifier case: for an unresolved bare identifier (see
ScriptAnalysis-001) neither pass fires. GetSyntacticScopes deliberately returns
nothing for dotless names (:210-212, "bare local identifiers cannot reach a forbidden
namespace") — which is true only while Pass 1 resolves them; once resolution fails the
assumption breaks.
This is the structural root of ScriptAnalysis-001 and is recorded separately because it is a design property (the syntactic pass is not self-sufficient against bare names) that should be acknowledged even after the TPA-fallback hole is closed: the defence is single-layered, not double-layered, for the bare-identifier vector.
Recommendation
Document explicitly that Pass 1 is the sole guard for bare (single-identifier)
forbidden references and that Pass 2 only backstops spelled forbidden namespaces. If
genuine defence-in-depth is wanted for bare names, add a simple-name deny-set to Pass 2
(the simple names of the forbidden root types — Process, WebClient, Marshal,
Registry, Thread, File, Directory, etc.), accepting the small false-positive
risk on a script's own identically-named local, which a security gate should tolerate.
Resolution
Deferred 2026-06-20: strengthening Pass-2 (syntactic) so a bare forbidden identifier is caught without Pass-1 symbol resolution is a defence-in-depth enhancement; the production path (TPA present, two-layer compile gate on most call sites) is sound. Recorded for a follow-up.
ScriptAnalysis-003 — Adversarial test corpus has bypass-relevant gaps
| Severity | Medium |
| Category | Testing coverage |
| Status | Resolved |
| Location | tests/ZB.MOM.WW.ScadaBridge.ScriptAnalysis.Tests/ScriptTrustValidatorTests.cs; RoslynScriptCompilerTests.cs |
Description
The reject/allow corpus is solid for the spelled-namespace, alias, using static,
global::, reflection-gateway, generic-argument, and nested-lambda vectors. For a
security-critical trust boundary, however, several bypass-relevant cases are untested:
- TPA-fallback degradation (ScriptAnalysis-001) — no test forces
AnalysisReferencesonto the minimal set and asserts that bareProcess/WebClientis still rejected. This is the highest-value missing test. - Extension methods — no test that a forbidden-namespace extension method invoked
in receiver-position (
x.SomeForbiddenExt()) is resolved and flagged. - Verbatim / Unicode-escaped identifiers —
@dynamic,dynamic,Activator, orGetTypeare not exercised;VisitIdentifierNamecomparesIdentifier.ValueText, which decodes Unicode escapes (so it likely holds), but the case is not pinned, anddynamicis matched viatext == "dynamic"(:380) which is a separate path from theForbiddenIdentifiersset. unsafe/pointer/stackalloc— relies implicitly onScriptOptionsdefaultingAllowUnsafe=false; not asserted.- Comment/trivia and string-literal payloads — no test that a forbidden namespace inside a comment or string is not flagged (false-positive guard) and that a forbidden token reconstructed only at runtime from strings is correctly not the validator's concern (documents the sandbox caveat).
Recommendation
Add the cases above, prioritising the TPA-fallback test (inject a minimal-reference
build of AnalysisReferences or factor the reference set so a test can force the
fallback) and the extension-method test. Add a verbatim/Unicode-escape identifier
reject test for dynamic/Activator/GetType.
Resolution
Resolved 2026-06-20 (commit fd618cf1): added 18 adversarial tests — TPA-fallback
bare-Process/Socket, extension-method invocation, verbatim/Unicode-escaped identifiers,
unsafe blocks (benign vs forbidden-API-inside), and comment/string-literal
false-positive guards.
ScriptAnalysis-004 — ParseDiagnostics/Compile drop warnings though the design doc promises "errors and warnings"
| Severity | Low |
| Category | Design-document adherence |
| Status | Resolved |
| Location | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/RoslynScriptCompiler.cs:30-33, :73-76 |
Description
REQ-SA-3 states Compile returns the diagnostics "filtered to errors and warnings"
and ParseDiagnostics returns "syntax-level diagnostics (errors and warnings)". The
code filters d.Severity == DiagnosticSeverity.Error only in both methods
(RoslynScriptCompiler.cs:31 and :74), so warnings are silently dropped. Filtering
to errors-only is arguably the safer choice for a compile gate (a warning should not
block a deploy), but the code and the spec disagree, and a future maintainer reading
the doc will expect warnings in the returned list.
Recommendation
Reconcile: either change the doc (REQ-SA-3) to say "errors only", or include warnings and let callers decide. Given the gate semantics, updating the doc to "error-severity diagnostics" is the cleaner fix.
Resolution
Resolved 2026-06-20 (commit fd618cf1): corrected the design doc (REQ-SA-3) to
'error-severity only', matching the compile wrapper's existing behaviour (a warning must
not block a deploy gate). Code unchanged.
ScriptAnalysis-005 — nameof(forbiddenType) flagging is inconsistent between passes and across resolution states
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Deferred |
| Location | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs:42-48, :99-136, :298-325 |
Description
The class comment (:42-48) states that a forbidden type inside nameof(...) is
"deliberately flagged" as a conservative fail-safe, and Rejects_NameOf_ForbiddenType
pins nameof(System.IO.File). However the flagging is incidental, not deliberate, and
inconsistent: nameof(System.IO.File) is caught because the inner System.IO.File is
a QualifiedNameSyntax/member access that both passes inspect directly — the
nameof wrapper is irrelevant. A bare nameof(Process) (after using System.Diagnostics;, with full TPA loaded) resolves Process to a real symbol and
is flagged by Pass 1; but the same nameof(Process) under the minimal-reference
fallback resolves to nothing and is not flagged. So the "deliberate fail-safe"
behaviour actually varies with the reference set, and nameof — which produces only a
compile-time string and reaches no API — is a genuine false positive that will reject a
legitimate script doing nameof(System.Net.IPAddress) for a log message.
Recommendation
Decide the intended semantics explicitly. If nameof should be allowed (it reaches no
runtime API), special-case NameOfExpression/InvocationExpression named nameof to
suppress the inner-reference report. If it should be rejected, make that uniform
regardless of resolution state (tie to a simple-name check), and keep the test. Either
way, align the comment with the actual, deterministic behaviour.
Resolution
Deferred 2026-06-20: the nameof(forbiddenType) over-flagging is a documented
deliberate fail-safe; whether nameof (which reaches no API) should be allowed vs
uniformly rejected is a design-owner decision. Recorded; no behaviour change this pass.
ScriptAnalysis-006 — No caching or throttling of Roslyn compilations; full-framework compilation rebuilt per call
| Severity | Low |
| Category | Performance & resource management |
| Status | Deferred |
| Location | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs:73-97; RoslynScriptCompiler.cs:50-71 |
Description
FindViolations parses the script and builds a fresh CSharpCompilation against the
entire framework reference set (AnalysisReferences, potentially hundreds of
MetadataReferences) on every call (:87-95). RoslynScriptCompiler.Compile
similarly creates a new CSharpScript and calls .Compile() per call. Roslyn
compilation is the expensive part of these calls. The metadata references are cached
(built once in AnalysisReferences), which is the main cost, so this is Low rather
than Medium — but for the Central UI editor path (which can call the validator on every
keystroke-debounced analysis) and for batch template validation (many scripts per
deploy), the absence of any per-source memoisation or a bounded compilation cache means
repeated work. There is no back-pressure or concurrency cap either; the Inbound API
review (InboundAPI-009) already noted uncapped recompilation downstream.
Recommendation
Consider a small bounded cache keyed on a hash of (code, globalsType) for Compile
results and on code for FindViolations verdicts (the verdict is a pure function of
the source and the static policy). At minimum, document that callers on hot paths
(editor live-analysis) should debounce and cache, and confirm the Central UI debounces
before calling.
Resolution
Deferred 2026-06-20: compilation caching/throttling (the full-framework
CSharpCompilation is rebuilt per FindViolations call) is a performance enhancement,
not a correctness defect. Recorded for a follow-up.
ScriptAnalysis-007 — FindViolations extraReferences XML-doc contradicts the Central UI consumer; AnalysisReferences undocumented in REQ-SA-2
| Severity | Low |
| Category | Documentation & comments |
| Status | Resolved |
| Location | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs:57-64; ScriptTrustPolicy.cs:125-144; docs/requirements/Component-ScriptAnalysis.md:74-80 |
Description
Two doc/code mismatches, neither a vulnerability:
-
The
extraReferencesXML-doc onFindViolations(:57-64) says "Forbidden references are NOT added here". But the Central UI run gate callsFindViolations(tree.ToString(), compilation.References)(CentralUI/ScriptAnalysis/ScriptAnalysisService.cs:1319-1320), forwarding the full BCL surface asextraReferences. This is in fact safe (the deny-list is by namespace/type, so adding references can only improve resolution, never produce a false allow — and the policy comment atScriptTrustPolicy.cs:138-142says exactly that), but theFindViolationsdoc as written implies the caller must not pass forbidden assemblies, which is both unnecessary and contradicted in practice. -
REQ-SA-2 in the design doc describes Pass 1 as using "the full trusted-platform reference set from
ScriptTrustPolicy.DefaultReferences". The code uses a separateScriptTrustPolicy.AnalysisReferences(the full framework), andDefaultReferencesis explicitly the minimal set used byRoslynScriptCompiler. The doc conflates the two and never mentionsAnalysisReferencesor the TPA mechanism — material because that mechanism is the heart of the security verdict (and of ScriptAnalysis-001).
Recommendation
Rewrite the extraReferences doc to state that extra references only widen symbol
resolution and never whitelist a forbidden API. Update REQ-SA-2 to describe
AnalysisReferences (full-framework, TPA-sourced) versus DefaultReferences (minimal,
runtime-fidelity), and note the TPA-fallback behaviour.
Resolution
Resolved 2026-06-20 (commit fd618cf1): corrected the FindViolations
extraReferences XML doc (extra references only widen resolution, never whitelist) and
documented AnalysisReferences vs DefaultReferences in REQ-SA-2.
ScriptAnalysis-008 — Static trust gate is defence-in-depth, not a sandbox; correctly documented but worth recording as a standing risk
| Severity | Low |
| Category | Security |
| Status | Won't Fix |
| Location | src/ZB.MOM.WW.ScadaBridge.ScriptAnalysis/ScriptTrustValidator.cs:34-40; docs/requirements/Component-ScriptAnalysis.md:151 |
Description
The component is explicit (both in code comments and REQ-SA-5) that it is a
compile-time deny-list, not a runtime sandbox: scripts execute in-process, and a
sufficiently determined script could reconstruct a forbidden API at runtime via paths
the static walker cannot see (e.g. building type names from runtime strings, or any
gap in the deny-list). The reflection-gateway hardening narrows this, and the
dynamic/Activator bans close the obvious late-binding routes, but the residual risk
is real and inherent to in-process execution of partially-trusted C#. This is recorded
(not as a defect of this module, which behaves as designed) so the standing risk is
visible in the audit trail and is revisited if/when the deferred runtime boundary
(restricted AssemblyLoadContext / curated reference set / out-of-process sandbox) is
scoped.
Recommendation
Keep the caveat prominent. Track the runtime-boundary work as a roadmap item; in the
meantime ensure the curated execution reference sets (SiteRuntime ScriptAssemblies,
RoslynScriptCompiler DefaultReferences) stay minimal so that even a deny-list miss
cannot bind a forbidden type at execution time (this is the real second layer of
defence for the compile-running consumers).
Resolution
Won't Fix 2026-06-20: the static gate is intentionally defence-in-depth, not a runtime sandbox — this is the explicitly-documented design posture, not a defect. Recorded as a standing risk for visibility.