Resolve all 33 medium-severity architecture review findings
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.
Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.
Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.
Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.
Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.
Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.
Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>Thread-safe console sink that never retains more than its character budget.</summary>
|
||||
public sealed class BoundedSandboxWriter(int capacity) : StringWriter
|
||||
{
|
||||
private readonly StringBuilder _retained = new(Math.Max(0, capacity));
|
||||
private readonly object _gate = new();
|
||||
private long _dropped;
|
||||
public int RetainedCharacters { get { lock (_gate) return _retained.Length; } }
|
||||
public long DroppedCharacters { get { lock (_gate) return _dropped; } }
|
||||
public override void Write(char value) { Span<char> one = stackalloc char[1]; one[0] = value; Write(one); }
|
||||
public override void Write(string? value) { if (value is not null) Write(value.AsSpan()); }
|
||||
public override void Write(char[] buffer, int index, int count) => Write(buffer.AsSpan(index, count));
|
||||
public override void Write(ReadOnlySpan<char> buffer)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var take = Math.Min(buffer.Length, Math.Max(0, capacity - _retained.Length));
|
||||
_retained.Append(buffer[..take]);
|
||||
_dropped = Math.Min(long.MaxValue - (buffer.Length - take), _dropped) + buffer.Length - take;
|
||||
}
|
||||
}
|
||||
public override void WriteLine(string? value) { lock (_gate) { Write(value); Write(NewLine); } }
|
||||
public override void WriteLine() => Write(NewLine);
|
||||
public override string ToString() { lock (_gate) return _retained.ToString() + (_dropped > 0 ? "\n… (truncated)" : ""); }
|
||||
}
|
||||
|
||||
/// <summary>Rejects serialized data once its byte budget is reached, without retaining the excess.</summary>
|
||||
public sealed class BoundedSandboxStream(int capacity) : MemoryStream
|
||||
{
|
||||
public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
|
||||
public override void Write(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (Length + buffer.Length > capacity) throw new InvalidDataException("Sandbox output exceeds its byte budget.");
|
||||
var bounded = buffer.ToArray();
|
||||
base.Write(bounded, 0, bounded.Length);
|
||||
}
|
||||
public override void WriteByte(byte value) { Span<byte> one = stackalloc byte[1]; one[0] = value; Write(one); }
|
||||
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
|
||||
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; }
|
||||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
|
||||
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer, offset, count); return Task.CompletedTask; }
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Data.Common;
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>Bounded private IPC for disposable, killable Test Run processes.</summary>
|
||||
public static class SandboxWorkerProtocol
|
||||
{
|
||||
private const int FrameLimit = 1024 * 1024;
|
||||
private static readonly SemaphoreSlim Admission = new(4, 4);
|
||||
private static readonly JsonSerializerOptions Json = new() { IgnoreReadOnlyProperties = true };
|
||||
private sealed record Frame(string Kind, string? Method, JsonElement Data, string? Error = null);
|
||||
private sealed record Start(SandboxRunRequest Request, bool External, bool Database, bool Bound);
|
||||
private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open);
|
||||
|
||||
/// <summary>Runs one child under a deadline; a slot remains charged until confirmed process exit.</summary>
|
||||
public static async Task<SandboxRunResult> RunAsync(SandboxRunRequest request, IServiceProvider services,
|
||||
ISharedScriptCatalog catalog, CancellationToken caller)
|
||||
=> await RunCoreAsync(request, services, catalog, caller);
|
||||
|
||||
internal static async Task<SandboxRunResult> RunCoreAsync(SandboxRunRequest request, IServiceProvider services,
|
||||
ISharedScriptCatalog catalog, CancellationToken caller, Action<int>? started = null)
|
||||
{
|
||||
var seconds = Math.Clamp(request.TimeoutSeconds ?? 5, 1, 10);
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(caller);
|
||||
deadline.CancelAfter(TimeSpan.FromSeconds(seconds));
|
||||
var clock = Stopwatch.StartNew();
|
||||
var acquired = false;
|
||||
Process? process = null;
|
||||
Task? stderr = null;
|
||||
CancellationTokenRegistration terminateOnDeadline = default;
|
||||
try
|
||||
{
|
||||
await Admission.WaitAsync(deadline.Token); acquired = true;
|
||||
ISandboxInstanceGateway? gateway = null;
|
||||
if (request.Kind != ScriptKind.InboundApi && !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
|
||||
{
|
||||
var locator = services.GetService<IInstanceLocator>();
|
||||
var comms = services.GetService<CommunicationService>();
|
||||
var site = locator is null ? null : await locator.GetSiteIdForInstanceAsync(request.BindInstanceUniqueName, deadline.Token);
|
||||
if (site is null || comms is null) throw new ScriptSandboxException("Cannot bind instance: cross-site communication or deployment is unavailable.");
|
||||
gateway = new SandboxInstanceGateway(comms, site, request.BindInstanceUniqueName, deadline.Token);
|
||||
}
|
||||
var worker = Path.Combine(AppContext.BaseDirectory, "ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker.dll");
|
||||
if (!File.Exists(worker)) throw new InvalidOperationException("Sandbox worker is not installed.");
|
||||
var start = new ProcessStartInfo("dotnet") { UseShellExecute = false, RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true };
|
||||
start.ArgumentList.Add(worker);
|
||||
// A finite heap bounds accidental allocation in return serialization and script code.
|
||||
start.Environment["DOTNET_GCHeapHardLimit"] = "20000000"; // 512 MiB, hexadecimal runtime setting.
|
||||
process = Process.Start(start) ?? throw new InvalidOperationException("Could not start sandbox worker.");
|
||||
terminateOnDeadline = deadline.Token.Register(static state =>
|
||||
{
|
||||
var child = (Process)state!;
|
||||
try { if (!child.HasExited) child.Kill(entireProcessTree: true); }
|
||||
catch (InvalidOperationException) { /* Concurrent normal process exit. */ }
|
||||
}, process);
|
||||
started?.Invoke(process.Id);
|
||||
stderr = DrainAsync(process.StandardError.BaseStream);
|
||||
await WriteAsync(process.StandardInput.BaseStream, new Frame("start", null,
|
||||
Encode(new Start(request, services.GetService<IExternalSystemClient>() is not null,
|
||||
services.GetService<IDatabaseGateway>() is not null, gateway is not null))), deadline.Token);
|
||||
while (true)
|
||||
{
|
||||
var frame = await ReadAsync(process.StandardOutput.BaseStream, deadline.Token);
|
||||
if (frame.Kind == "result")
|
||||
return frame.Data.Deserialize<SandboxRunResult>(Json) ?? throw new InvalidDataException("Missing sandbox result.");
|
||||
if (frame.Kind != "call") throw new InvalidDataException("Unexpected sandbox message.");
|
||||
deadline.Token.ThrowIfCancellationRequested();
|
||||
Frame reply;
|
||||
try
|
||||
{
|
||||
var value = await DispatchAsync(frame.Method!, frame.Data, services, catalog, gateway, deadline.Token);
|
||||
deadline.Token.ThrowIfCancellationRequested();
|
||||
reply = new Frame("reply", null, Encode(value));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{ reply = new Frame("reply", null, Encode(null), ex is ScriptSandboxException ? ex.Message : "Sandbox gateway request failed."); }
|
||||
await WriteAsync(process.StandardInput.BaseStream, reply, deadline.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{ return new(false, null, null, "", $"Script execution exceeded the {seconds}-second sandbox timeout or was canceled.", SandboxErrorKind.Timeout, clock.ElapsedMilliseconds, null); }
|
||||
catch (ScriptSandboxException ex)
|
||||
{ return new(false, null, null, "", ex.Message, SandboxErrorKind.SandboxLimitation, clock.ElapsedMilliseconds, null); }
|
||||
catch (Exception)
|
||||
{ return new(false, null, null, "", "Sandbox worker exited or exceeded its resource budget.", SandboxErrorKind.RuntimeError, clock.ElapsedMilliseconds, null); }
|
||||
finally
|
||||
{
|
||||
deadline.Cancel(); // Stop accepting/brokering new operations before termination.
|
||||
terminateOnDeadline.Dispose();
|
||||
if (process is not null)
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
||||
catch (InvalidOperationException) when (process.HasExited) { }
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
if (stderr is not null) await stderr;
|
||||
process.Dispose();
|
||||
}
|
||||
if (acquired) Admission.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DrainAsync(Stream stream)
|
||||
{ var buffer = new byte[4096]; while (await stream.ReadAsync(buffer) != 0) { } }
|
||||
|
||||
private static async Task<object?> DispatchAsync(string method, JsonElement args, IServiceProvider services,
|
||||
ISharedScriptCatalog catalog, ISandboxInstanceGateway? gateway, CancellationToken ct)
|
||||
{
|
||||
string Text(int i) => args[i].GetString()!;
|
||||
IReadOnlyDictionary<string, object?>? Parameters(int i) => args[i].ValueKind == JsonValueKind.Null ? null
|
||||
: args[i].EnumerateObject().ToDictionary(p => p.Name, p => ScriptAnalysisService.JsonElementToObject(p.Value));
|
||||
switch (method)
|
||||
{
|
||||
case "shared": return await catalog.GetByNameAsync(Text(0), ct);
|
||||
case "external": return await services.GetRequiredService<IExternalSystemClient>().CallAsync(Text(0), Text(1), Parameters(2), ct);
|
||||
case "cachedExternal": return await services.GetRequiredService<IExternalSystemClient>().CachedCallAsync(Text(0), Text(1), Parameters(2), Text(3), ct);
|
||||
case "cachedDb": return await services.GetRequiredService<IDatabaseGateway>().CachedWriteAsync(Text(0), Text(1), Parameters(2), Text(3), ct);
|
||||
case "connection":
|
||||
var source = services.GetRequiredService<IDatabaseGateway>() as ISandboxDatabaseConnectionSource
|
||||
?? throw new ScriptSandboxException("Database worker handoff is unavailable for this provider.");
|
||||
return new ConnectionInfo("Microsoft.Data.SqlClient.SqlConnection",
|
||||
await source.GetSandboxConnectionStringAsync(Text(0), ct), true);
|
||||
case "get": return await (gateway ?? throw new ScriptSandboxException("Instance not bound.")).GetAttributeAsync(Text(0), ct);
|
||||
case "set": await gateway!.SetAttributeAsync(Text(0), Text(1), ct); return null;
|
||||
case "script": return await gateway!.CallScriptAsync(Text(0), Parameters(1), ct);
|
||||
case "wait": return await gateway!.WaitForAttributeAsync(Text(0), args[1].GetString(), TimeSpan.FromTicks(args[2].GetInt64()), args[3].GetBoolean(), ct);
|
||||
default: throw new ScriptSandboxException("Unknown sandbox operation.");
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement Encode(object? value)
|
||||
{
|
||||
using var buffer = new BoundedSandboxStream(FrameLimit - 4096);
|
||||
JsonSerializer.Serialize(buffer, value, Json);
|
||||
using var document = JsonDocument.Parse(buffer.GetBuffer().AsMemory(0, (int)buffer.Length));
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task WriteAsync(Stream stream, Frame frame, CancellationToken ct)
|
||||
{
|
||||
using var bytes = new BoundedSandboxStream(FrameLimit);
|
||||
JsonSerializer.Serialize(bytes, frame, Json);
|
||||
var prefix = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(prefix, (int)bytes.Length);
|
||||
await stream.WriteAsync(prefix, ct);
|
||||
await stream.WriteAsync(bytes.GetBuffer().AsMemory(0, (int)bytes.Length), ct);
|
||||
await stream.FlushAsync(ct);
|
||||
}
|
||||
private static async Task<Frame> ReadAsync(Stream stream, CancellationToken ct)
|
||||
{
|
||||
var prefix = new byte[4]; await stream.ReadExactlyAsync(prefix, ct);
|
||||
var length = BinaryPrimitives.ReadInt32LittleEndian(prefix);
|
||||
if (length is < 1 or > FrameLimit) throw new InvalidDataException("Sandbox frame budget exceeded.");
|
||||
var data = new byte[length]; await stream.ReadExactlyAsync(data, ct);
|
||||
return JsonSerializer.Deserialize<Frame>(data, Json) ?? throw new InvalidDataException("Invalid sandbox frame.");
|
||||
}
|
||||
|
||||
/// <summary>Worker executable entry. Protocol streams are captured before script console routing.</summary>
|
||||
public static async Task RunWorkerAsync()
|
||||
{
|
||||
var input = Console.OpenStandardInput(); var output = Console.OpenStandardOutput();
|
||||
var first = await ReadAsync(input, CancellationToken.None);
|
||||
var start = first.Data.Deserialize<Start>(Json) ?? throw new InvalidDataException("Missing worker request.");
|
||||
var rpc = new Rpc(input, output);
|
||||
var services = new ServiceCollection();
|
||||
if (start.External) services.AddSingleton<IExternalSystemClient>(rpc);
|
||||
if (start.Database) services.AddSingleton<IDatabaseGateway>(rpc);
|
||||
if (start.Bound) services.AddSingleton<ISandboxInstanceGateway>(rpc);
|
||||
using var provider = services.BuildServiceProvider();
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var runner = new ScriptAnalysisService(rpc, cache, provider);
|
||||
var result = await runner.RunInWorkerAsync(start.Request, CancellationToken.None);
|
||||
await WriteAsync(output, new Frame("result", null, Encode(result)), CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class Rpc(Stream input, Stream output) : ISharedScriptCatalog, IExternalSystemClient, IDatabaseGateway, ISandboxInstanceGateway
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1);
|
||||
private async Task<JsonElement> Call(string method, object?[] args, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await WriteAsync(output, new Frame("call", method, Encode(args)), ct);
|
||||
var reply = await ReadAsync(input, ct);
|
||||
if (reply.Error is not null) throw new ScriptSandboxException(reply.Error);
|
||||
return reply.Data;
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
public Task<IReadOnlyList<ScriptShape>> GetShapesAsync() => Task.FromResult<IReadOnlyList<ScriptShape>>([]);
|
||||
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> (await Call("shared", [name], cancellationToken)).Deserialize<SharedScriptSource>(Json);
|
||||
public async Task<ExternalCallResult> CallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, CancellationToken cancellationToken = default)
|
||||
=> (await Call("external", [systemName, methodName, parameters], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
|
||||
public async Task<ExternalCallResult> CachedCallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
|
||||
=> (await Call("cachedExternal", [systemName, methodName, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
|
||||
public async Task<ExternalCallResult> CachedWriteAsync(string connectionName, string sql, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
|
||||
=> (await Call("cachedDb", [connectionName, sql, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
|
||||
public async Task<DbConnection> GetConnectionAsync(string connectionName, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var info = (await Call("connection", [connectionName], cancellationToken)).Deserialize<ConnectionInfo>(Json)!;
|
||||
DbConnection connection = info.Provider switch {
|
||||
"Microsoft.Data.SqlClient.SqlConnection" => new Microsoft.Data.SqlClient.SqlConnection(info.ConnectionString),
|
||||
_ => throw new ScriptSandboxException("Unsupported database provider.") };
|
||||
if (info.Open) await connection.OpenAsync(cancellationToken);
|
||||
return connection;
|
||||
}
|
||||
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
|
||||
=> ScriptAnalysisService.JsonElementToObject(await Call("get", [canonicalName], ct));
|
||||
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) => await Call("set", [canonicalName, value], ct);
|
||||
public async Task<object?> CallScriptAsync(string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
|
||||
=> ScriptAnalysisService.JsonElementToObject(await Call("script", [canonicalScriptName, parameters], ct));
|
||||
public async Task<WaitResult> WaitForAttributeAsync(string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
|
||||
=> (await Call("wait", [canonicalName, targetValueEncoded, timeout.Ticks, requireGoodQuality], ct)).Deserialize<WaitResult>(Json)!;
|
||||
}
|
||||
}
|
||||
@@ -189,20 +189,9 @@ public class ScriptAnalysisService
|
||||
private const int SandboxMaxCallSharedDepth = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cap on concurrently executing Test Runs (arch-review WP2.4).
|
||||
/// <para>
|
||||
/// A sandbox run occupies a thread-pool thread for its whole duration — the
|
||||
/// <c>SandboxScriptHost</c> attribute accessors are synchronous by contract and block on
|
||||
/// cross-site I/O (<c>GetAwaiter().GetResult()</c>), so the thread is <em>parked</em>, not
|
||||
/// merely busy, for up to the 10s sandbox timeout. Unbounded, a handful of Designers
|
||||
/// each hitting Run on a slow-bound script could park enough pool threads to starve the
|
||||
/// Blazor circuits sharing this process. The gate bounds that: excess runs wait for a
|
||||
/// slot instead of taking a thread, and the wait is charged to the caller's own timeout.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Static because <c>ScriptAnalysisService</c> is registered scoped — a per-instance gate
|
||||
/// would be per-request and bound nothing.
|
||||
/// </para>
|
||||
/// Secondary guard inside the disposable worker. Parent admission and process
|
||||
/// termination are enforced by <see cref="SandboxWorkerProtocol"/>; synchronous
|
||||
/// script helper calls never occupy a central Blazor thread.
|
||||
/// </summary>
|
||||
private static readonly SemaphoreSlim SandboxRunGate =
|
||||
new(SandboxMaxConcurrentRuns, SandboxMaxConcurrentRuns);
|
||||
@@ -214,7 +203,7 @@ public class ScriptAnalysisService
|
||||
private const int SandboxMaxConcurrentRuns = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and runs a script in the central process. The globals surface
|
||||
/// Compiles and runs a script in a disposable worker process. The globals surface
|
||||
/// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
|
||||
/// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
|
||||
/// scripts against <see cref="SandboxInboundScriptHost"/>.
|
||||
@@ -242,7 +231,11 @@ public class ScriptAnalysisService
|
||||
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
|
||||
/// <param name="ct">Cancellation token for the caller; combined with an internal timeout CTS.</param>
|
||||
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
|
||||
public virtual async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
|
||||
public virtual Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
|
||||
=> SandboxWorkerProtocol.RunAsync(request, _services, _sharedScripts, ct);
|
||||
|
||||
/// <summary>Execution core invoked only by the disposable worker executable.</summary>
|
||||
public async Task<SandboxRunResult> RunInWorkerAsync(SandboxRunRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
@@ -294,7 +287,7 @@ public class ScriptAnalysisService
|
||||
// Trust-model gate: the documented forbidden-API set is
|
||||
// enforced HERE, before execution — not merely surfaced as an editor hint.
|
||||
// Without this, a Design-role user could run arbitrary file/process/
|
||||
// reflection/network code in the central host process.
|
||||
// reflection/network code in the worker process.
|
||||
var trustViolations = EnforceTrustModel(script.GetCompilation());
|
||||
if (trustViolations.Count > 0)
|
||||
{
|
||||
@@ -313,9 +306,9 @@ public class ScriptAnalysisService
|
||||
// Optional instance binding: when the Test Run targets a deployed
|
||||
// instance, Instance.GetAttribute/SetAttribute/CallScript and the
|
||||
// Attributes/Children/Parent accessors route to it cross-site.
|
||||
ISandboxInstanceGateway? instanceGateway = null;
|
||||
var instanceLabel = "test-run";
|
||||
if (request.Kind != ScriptKind.InboundApi
|
||||
ISandboxInstanceGateway? instanceGateway = _services.GetService<ISandboxInstanceGateway>();
|
||||
var instanceLabel = request.BindInstanceUniqueName ?? "test-run";
|
||||
if (instanceGateway is null && request.Kind != ScriptKind.InboundApi
|
||||
&& !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
|
||||
{
|
||||
var bindName = request.BindInstanceUniqueName.Trim();
|
||||
@@ -457,7 +450,7 @@ public class ScriptAnalysisService
|
||||
// restore Console.Out while the other is still writing. SandboxConsoleCapture
|
||||
// installs routing writers once and scopes capture to this call-tree only.
|
||||
var (captureOut, captureError) = SandboxConsoleCapture.Install();
|
||||
var captured = new StringWriter();
|
||||
var captured = new BoundedSandboxWriter(SandboxMaxConsoleChars);
|
||||
using var outScope = captureOut.BeginCapture(captured);
|
||||
using var errorScope = captureError.BeginCapture(captured);
|
||||
|
||||
@@ -485,7 +478,7 @@ public class ScriptAnalysisService
|
||||
Success: true,
|
||||
ReturnValueJson: returnJson,
|
||||
ReturnTypeName: returnType,
|
||||
ConsoleOutput: TruncateConsole(captured.ToString()),
|
||||
ConsoleOutput: captured.ToString(),
|
||||
Error: null,
|
||||
ErrorKind: SandboxErrorKind.None,
|
||||
DurationMs: stopwatch.ElapsedMilliseconds,
|
||||
@@ -495,14 +488,14 @@ public class ScriptAnalysisService
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()), sandboxEx.Message,
|
||||
captured.ToString(), sandboxEx.Message,
|
||||
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()),
|
||||
captured.ToString(),
|
||||
$"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.",
|
||||
SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
@@ -513,11 +506,11 @@ public class ScriptAnalysisService
|
||||
if (inner is ScriptSandboxException sx)
|
||||
{
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()), sx.Message,
|
||||
captured.ToString(), sx.Message,
|
||||
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()),
|
||||
captured.ToString(),
|
||||
$"{inner.GetType().Name}: {inner.Message}",
|
||||
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
@@ -542,7 +535,7 @@ public class ScriptAnalysisService
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
internal static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
@@ -561,25 +554,17 @@ public class ScriptAnalysisService
|
||||
|
||||
private static (string Json, string TypeName) SerializeReturn(object? value)
|
||||
{
|
||||
if (value == null) return ("null", "null");
|
||||
var typeName = value.GetType().Name;
|
||||
var typeName = value?.GetType().FullName ?? "null";
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, SandboxReturnJsonOptions);
|
||||
if (json.Length > SandboxMaxReturnJsonChars)
|
||||
json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
|
||||
return (json, typeName);
|
||||
if (value is string text && text.Length > SandboxMaxReturnJsonChars)
|
||||
return ("\"<return value truncated>\"", typeName);
|
||||
using var buffer = new BoundedSandboxStream(SandboxMaxReturnJsonChars);
|
||||
JsonSerializer.Serialize(buffer, value, SandboxReturnJsonOptions);
|
||||
return (Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length), typeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ($"\"<unserializable: {ex.Message}>\"", typeName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateConsole(string text)
|
||||
{
|
||||
if (text.Length <= SandboxMaxConsoleChars) return text;
|
||||
return text[..SandboxMaxConsoleChars] + "\n… (truncated)";
|
||||
catch (InvalidDataException) { return ("\"<return value truncated>\"", typeName); }
|
||||
catch (Exception) { return ("\"<unserializable>\"", typeName); }
|
||||
}
|
||||
|
||||
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
|
||||
|
||||
@@ -55,7 +55,7 @@ public static class ServiceCollectionExtensions
|
||||
// racing AuditFilterBar's site enumeration on the shared scoped context.
|
||||
services.AddScoped<IAuditLogQueryService>(sp => new AuditLogQueryService(
|
||||
sp.GetRequiredService<IServiceScopeFactory>(),
|
||||
sp.GetRequiredService<ICentralHealthAggregator>()));
|
||||
sp.GetRequiredService<ICentralHealthAggregator>(), sp.GetRequiredService<AuthenticationStateProvider>()));
|
||||
|
||||
// Server-side streaming CSV export.
|
||||
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
@@ -45,6 +48,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
||||
|
||||
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
|
||||
private readonly IServiceScopeFactory? _scopeFactory;
|
||||
private readonly AuthenticationStateProvider? _auth;
|
||||
|
||||
// Test seam: a directly-injected repository whose lifetime the test owns.
|
||||
// Null in the production ctor.
|
||||
@@ -68,9 +72,10 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
||||
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
|
||||
public AuditLogQueryService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ICentralHealthAggregator healthAggregator)
|
||||
ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null)
|
||||
{
|
||||
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
||||
_auth = auth;
|
||||
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
|
||||
}
|
||||
|
||||
@@ -171,7 +176,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
|
||||
// page's auto-load never shares the circuit-scoped context.
|
||||
await using var scope = _scopeFactory!.CreateAsyncScope();
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
||||
return await repository.GetExecutionTreeAsync(executionId, ct);
|
||||
if (_auth is null) throw new InvalidOperationException("Audit tree caller authentication is not configured.");
|
||||
var principal = (await _auth.GetAuthoritativeStateAsync()).User;
|
||||
if (principal.Identity?.IsAuthenticated != true || !principal.FindAll(JwtTokenService.RoleClaimType).Any(c => AuthorizationPolicies.OperationalAuditRoles.Contains(c.Value))) throw new UnauthorizedAccessException();
|
||||
var sites = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray();
|
||||
var permitted = principal.HasClaim(JwtTokenService.RoleClaimType, Roles.Administrator) || sites.Length == 0 ? null : sites;
|
||||
return await repository.GetExecutionTreeAsync(executionId, ct, permitted);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
Reference in New Issue
Block a user