fix(transport): close code-review gaps in the import trust gate + severity split (T19/T20)

Addresses the T20 security code-review findings:
- Gate template script + alarm Expression-trigger bodies too (they compile and
  execute at the site), not just script bodies — EnumerateTrustGatedScripts now
  extracts and vets the {"expression":"..."} C# via the same trust validator.
- Per-origin local-declaration exclusion: a helper declared in a template script
  no longer suppresses a genuinely-missing reference of the same name in an
  ApiMethod (a global set silently defeated the ApiMethod hard-blocker).
- Fail-closed: a ScriptTrustValidator throw on one pathological body is caught
  and surfaced as a hard blocker FOR THAT script (apply) / Blocker row (preview)
  instead of aborting the whole import.
- Corrected the misleading attribute-Value comment (typed literal, not a compiled
  expression) and documented the Pass-0 fail-fast rationale.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 02:32:28 -04:00
parent 92e713f1a1
commit 039cf69cbb
2 changed files with 233 additions and 45 deletions
@@ -791,57 +791,66 @@ public sealed class BundleImporter : IBundleImporter
// it as a Blocker. This catches the documented use-case
// (HelperFn() / ErpSystem.Call()) without combinatorial blowup.
// #05-T19 severity split: track candidate references by ORIGIN. Template-
// script (and attribute-default-expression) references are advisory
// warnings — the design-time deploy gate re-validates them; ApiMethod
// references are hard blockers (no downstream gate). Local-function /
// method declarations in a body are excluded so a script's own helper
// isn't mistaken for a missing SharedScript.
// script references are advisory warnings — the design-time deploy gate
// re-validates them; ApiMethod references are hard blockers (no downstream
// gate). Local-function / method declarations are collected PER ORIGIN so a
// helper declared in a template script cannot suppress a genuinely-missing
// ApiMethod reference of the same name (and vice-versa) — a global set would
// silently defeat the ApiMethod hard-blocker.
var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
var locallyDeclared = new HashSet<string>(StringComparer.Ordinal);
var locallyDeclaredInTemplates = new HashSet<string>(StringComparer.Ordinal);
var locallyDeclaredInApiMethods = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates)
{
foreach (var s in t.Scripts)
{
CollectCallIdentifiers(s.Code, referencedFromTemplates);
CollectLocalDeclarations(s.Code, locallyDeclared);
CollectLocalDeclarations(s.Code, locallyDeclaredInTemplates);
}
foreach (var a in t.Attributes)
{
// Attribute.Value carries the design-time default expression, which
// can be script-callable. DataSourceReference is an OPC UA node
// address path (e.g. "ns=3;s=Tank.Level") owned by the device --
// it's never script source and must NOT be scanned, or the dot
// delimiter trips the heuristic into flagging the address segments
// as missing SharedScript/ExternalSystem references.
// Attribute.Value is a typed-literal default (int/double/string/list,
// decoded by AttributeValueCodec — never compiled), so it is not a
// trust surface; we still scan it for identifier-shaped tokens, but
// any finding is only ever an advisory template warning.
// DataSourceReference is an OPC UA node address path (e.g.
// "ns=3;s=Tank.Level") owned by the device and must NOT be scanned,
// or the dot delimiter trips the heuristic into flagging the address
// segments as missing SharedScript/ExternalSystem references.
CollectCallIdentifiers(a.Value, referencedFromTemplates);
CollectLocalDeclarations(a.Value, locallyDeclared);
CollectLocalDeclarations(a.Value, locallyDeclaredInTemplates);
}
}
foreach (var m in content.ApiMethods)
{
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
CollectLocalDeclarations(m.Script, locallyDeclared);
CollectLocalDeclarations(m.Script, locallyDeclaredInApiMethods);
}
// For each candidate, only report it if it looks like a resource
// reference (PascalCase, length > 1), isn't a well-known language /
// runtime / SQL token, isn't the body's own local declaration, AND
// isn't present anywhere we can satisfy it. A candidate that appears in
// an ApiMethod script is a hard Blocker; a template-only candidate is a
// non-blocking Warning.
// runtime / SQL token, AND isn't present anywhere we can satisfy it. A
// candidate genuinely unresolved in an ApiMethod (referenced there and NOT
// locally declared there) is a hard Blocker; a candidate genuinely
// unresolved only in a template is a non-blocking Warning.
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
allCandidates.UnionWith(referencedFromApiMethods);
foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
{
if (!LooksLikeResourceName(candidate)) continue;
if (KnownNonReferenceNames.Contains(candidate)) continue;
if (locallyDeclared.Contains(candidate)) continue;
var isShared = sharedScriptNames.Contains(candidate);
var isExternal = externalSystemNames.Contains(candidate);
if (isShared || isExternal) continue;
var fromApiMethod = referencedFromApiMethods.Contains(candidate);
var apiUnresolved = referencedFromApiMethods.Contains(candidate)
&& !locallyDeclaredInApiMethods.Contains(candidate);
var templateUnresolved = referencedFromTemplates.Contains(candidate)
&& !locallyDeclaredInTemplates.Contains(candidate);
if (!apiUnresolved && !templateUnresolved) continue; // satisfied by a local decl in its own origin
var fromApiMethod = apiUnresolved;
blockers.Add(new ImportPreviewItem(
EntityType: "Reference",
Name: candidate,
@@ -861,7 +870,29 @@ public sealed class BundleImporter : IBundleImporter
// as distinct rows.
foreach (var (kind, entityName, scriptLabel, code) in EnumerateTrustGatedScripts(content, resolutionMap: null))
{
foreach (var violation in ScriptTrustValidator.FindViolations(code))
IReadOnlyList<string> violations;
try
{
violations = ScriptTrustValidator.FindViolations(code);
}
catch (Exception ex)
{
// Fail closed (mirrors the apply-time gate): a validator throw on
// one body surfaces as a Blocker for that script, not a preview crash.
_logger?.LogWarning(ex,
"Script trust analysis threw for {Kind} '{EntityName}' script '{ScriptLabel}' during preview; surfacing as a Blocker.",
kind, entityName, scriptLabel);
blockers.Add(new ImportPreviewItem(
EntityType: kind,
Name: $"{entityName}.{scriptLabel}",
ExistingVersion: null,
IncomingVersion: null,
Kind: ConflictKind.Blocker,
FieldDiffJson: null,
BlockerReason: "Script trust analysis failed to complete — rejected."));
continue;
}
foreach (var violation in violations)
{
blockers.Add(new ImportPreviewItem(
EntityType: kind,
@@ -935,11 +966,15 @@ public sealed class BundleImporter : IBundleImporter
}
/// <summary>
/// #05-T20 — enumerates every executable script body the trust gate must
/// vet: non-Skip template scripts, shared scripts, and ApiMethod scripts.
/// A null <paramref name="resolutionMap"/> (preview time, before resolutions
/// are chosen) gates everything. Yields <c>(kind, entityName, scriptLabel,
/// code)</c> so callers can format either an error string or a preview row.
/// #05-T20 — enumerates every executable C# surface a bundle carries that the
/// trust gate must vet: non-Skip template scripts + their Expression-trigger
/// bodies, template alarm Expression-trigger bodies, shared scripts, and
/// ApiMethod scripts. Expression triggers compile and execute at the site
/// (`TriggerExpressionGlobals`), so they are a genuine trust surface, not just
/// script bodies. A null <paramref name="resolutionMap"/> (preview time, before
/// resolutions are chosen) gates everything. Yields <c>(kind, entityName,
/// scriptLabel, code)</c> so callers can format either an error string or a
/// preview row.
/// </summary>
private static IEnumerable<(string Kind, string EntityName, string ScriptLabel, string Code)> EnumerateTrustGatedScripts(
BundleContentDto content,
@@ -954,6 +989,19 @@ public sealed class BundleImporter : IBundleImporter
{
yield return ("Template", t.Name, s.Name, s.Code);
}
var scriptExpr = ExtractTriggerExpression(s.TriggerType, s.TriggerConfiguration);
if (!string.IsNullOrEmpty(scriptExpr))
{
yield return ("Template", t.Name, $"{s.Name} (trigger expression)", scriptExpr);
}
}
foreach (var a in t.Alarms)
{
var alarmExpr = ExtractTriggerExpression(a.TriggerType.ToString(), a.TriggerConfiguration);
if (!string.IsNullOrEmpty(alarmExpr))
{
yield return ("Template", t.Name, $"{a.Name} (alarm trigger expression)", alarmExpr);
}
}
}
foreach (var s in content.SharedScripts)
@@ -974,6 +1022,38 @@ public sealed class BundleImporter : IBundleImporter
}
}
/// <summary>
/// Extracts the C# boolean expression from an <c>{"expression":"…"}</c> trigger
/// configuration when the trigger type is <c>Expression</c> — the same shape
/// the site's <c>TriggerExpressionGlobals</c> compiles. Returns null for any
/// non-Expression trigger, empty config, or malformed JSON (nothing to gate).
/// </summary>
private static string? ExtractTriggerExpression(string? triggerType, string? triggerConfigJson)
{
if (triggerType is null
|| !triggerType.Equals("Expression", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(triggerConfigJson))
{
return null;
}
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.ValueKind == JsonValueKind.Object
&& doc.RootElement.TryGetProperty("expression", out var expr)
&& expr.ValueKind == JsonValueKind.String)
{
return expr.GetString();
}
}
catch (JsonException)
{
// Malformed trigger config isn't executable as an expression — nothing
// to gate here (a separate validation surfaces the malformed config).
}
return null;
}
private static bool IsSkipResolution(
Dictionary<(string, string), ImportResolution>? resolutionMap, string entityType, string name)
=> resolutionMap != null
@@ -4250,19 +4330,39 @@ public sealed class BundleImporter : IBundleImporter
var warnings = new List<string>();
// ---- Pass 0: script trust gate ----
// Bundle import is the fifth script-trust call site. Every executable
// script body (template, shared, ApiMethod) is run through the shared
// ScriptTrustValidator BEFORE name resolution — a forbidden-API verdict
// is authoritative (not the false-positive-prone name heuristic), so it
// is a HARD error for all kinds, and it must not be masked by a Pass-1
// name-resolution error. Task 15's verdict cache keeps repeat cost nil.
// Bundle import is the fifth script-trust call site. Every executable C#
// surface a bundle carries (template / shared / ApiMethod script bodies
// AND template script + alarm Expression-trigger bodies) is run through
// the shared ScriptTrustValidator BEFORE name resolution — a forbidden-API
// verdict is authoritative (not the false-positive-prone name heuristic),
// so it is a HARD error for all kinds, and it must not be masked by a
// Pass-1 name-resolution error. Task 15's verdict cache keeps repeat cost nil.
foreach (var (kind, entityName, scriptLabel, code) in EnumerateTrustGatedScripts(content, resolutionMap))
{
foreach (var violation in ScriptTrustValidator.FindViolations(code))
IReadOnlyList<string> violations;
try
{
violations = ScriptTrustValidator.FindViolations(code);
}
catch (Exception ex)
{
// Fail closed: a validator throw on one pathological body must not
// abort the whole import — treat it as a hard blocker FOR THAT
// script so the offender is named and the rest still surface.
_logger?.LogWarning(ex,
"Script trust analysis threw for {Kind} '{EntityName}' script '{ScriptLabel}'; treating as a hard blocker.",
kind, entityName, scriptLabel);
errors.Add($"{kind} '{entityName}' script '{scriptLabel}': trust analysis failed to complete — rejected.");
continue;
}
foreach (var violation in violations)
{
errors.Add($"{kind} '{entityName}' script '{scriptLabel}': {violation}");
}
}
// Fail fast on trust violations: the operator sees trust errors OR name-
// resolution errors, not both at once. `warnings` is always empty here
// (Pass 0 emits only errors), so nothing surfacable is dropped.
if (errors.Count > 0) return (errors, warnings);
// ---- Pass 1: minimal name-resolution scan ----
@@ -4314,7 +4414,8 @@ public sealed class BundleImporter : IBundleImporter
// are collected too so a script's own helper isn't flagged as missing.
var referencedFromTemplates = new HashSet<string>(StringComparer.Ordinal);
var referencedFromApiMethods = new HashSet<string>(StringComparer.Ordinal);
var locallyDeclared = new HashSet<string>(StringComparer.Ordinal);
var locallyDeclaredInTemplates = new HashSet<string>(StringComparer.Ordinal);
var locallyDeclaredInApiMethods = new HashSet<string>(StringComparer.Ordinal);
foreach (var t in content.Templates)
{
// Skip-resolved templates aren't being written, so their script
@@ -4324,17 +4425,18 @@ public sealed class BundleImporter : IBundleImporter
foreach (var s in t.Scripts)
{
CollectCallIdentifiers(s.Code, referencedFromTemplates);
CollectLocalDeclarations(s.Code, locallyDeclared);
CollectLocalDeclarations(s.Code, locallyDeclaredInTemplates);
}
foreach (var a in t.Attributes)
{
// Value can hold script-callable design-time expressions;
// Attribute.Value is a typed-literal default (never compiled), so it
// is not a trust surface; scanned only for the advisory name heuristic.
// DataSourceReference is an OPC UA address-space path (e.g.
// "ns=3;s=Tank.Level") and must NOT be scanned, or the dot
// delimiter will flag tag-path segments as missing references.
// Symmetric with DetectBlockersAsync.
CollectCallIdentifiers(a.Value, referencedFromTemplates);
CollectLocalDeclarations(a.Value, locallyDeclared);
CollectLocalDeclarations(a.Value, locallyDeclaredInTemplates);
}
}
foreach (var m in content.ApiMethods)
@@ -4342,26 +4444,32 @@ public sealed class BundleImporter : IBundleImporter
var resolution = ResolveOrDefault(resolutionMap, "ApiMethod", m.Name);
if (resolution.Action == ResolutionAction.Skip) continue;
CollectCallIdentifiers(m.Script, referencedFromApiMethods);
CollectLocalDeclarations(m.Script, locallyDeclared);
CollectLocalDeclarations(m.Script, locallyDeclaredInApiMethods);
}
// ApiMethod references are hard errors (no downstream deploy gate);
// template-only references are advisory warnings (the design-time deploy
// gate re-validates with a real compile).
// ApiMethod references genuinely unresolved (referenced there and NOT locally
// declared there) are hard errors (no downstream deploy gate); template-only
// unresolved references are advisory warnings (the deploy gate re-validates
// with a real compile). Local-declaration exclusion is per-origin so a
// template helper cannot mask a missing ApiMethod reference.
var allCandidates = new HashSet<string>(referencedFromTemplates, StringComparer.Ordinal);
allCandidates.UnionWith(referencedFromApiMethods);
foreach (var candidate in allCandidates.OrderBy(n => n, StringComparer.Ordinal))
{
if (!LooksLikeResourceName(candidate)) continue;
if (KnownNonReferenceNames.Contains(candidate)) continue;
if (locallyDeclared.Contains(candidate)) continue;
if (sharedScriptNames.Contains(candidate) || externalSystemNames.Contains(candidate)) continue;
if (referencedFromApiMethods.Contains(candidate))
var apiUnresolved = referencedFromApiMethods.Contains(candidate)
&& !locallyDeclaredInApiMethods.Contains(candidate);
var templateUnresolved = referencedFromTemplates.Contains(candidate)
&& !locallyDeclaredInTemplates.Contains(candidate);
if (apiUnresolved)
{
errors.Add(
$"Script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target.");
}
else
else if (templateUnresolved)
{
warnings.Add(
$"Template script references SharedScript or ExternalSystem '{candidate}' not present in bundle or target — advisory; re-validated at deploy time.");
@@ -367,6 +367,86 @@ public sealed class SemanticValidatorImportTests : IDisposable
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
}
[Fact]
public async Task Apply_AlarmExpressionTriggerWithForbiddenApi_HardBlocks()
{
// #05-T20 (code-review fix): an alarm's Expression trigger compiles and
// executes at the site, so a forbidden API in the trigger expression must
// be caught at import — not just in template/shared/ApiMethod script bodies.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("AlarmedTank");
t.Alarms.Add(new TemplateAlarm("Bad")
{
TriggerType = AlarmTriggerType.Expression,
TriggerConfiguration = "{\"expression\":\"System.Diagnostics.Process.GetCurrentProcess() != null\"}",
PriorityLevel = 1,
});
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
SemanticValidationException ex = default!;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "AlarmedTank", ResolutionAction.Add, null),
},
user: "bob"));
}
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
}
[Fact]
public async Task Apply_ApiMethodReference_NotSuppressedByTemplateLocalFunction()
{
// #05-T19 (code-review fix): a local function declared in a TEMPLATE script
// must not suppress a genuinely-missing reference of the same name in an
// ApiMethod — the ApiMethod finding stays a HARD error. A pre-fix global
// locallyDeclared set silently defeated this.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Helper");
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
"init",
"decimal SharedHelper(decimal x) => x * 2; return SharedHelper(1m);"));
ctx.Templates.Add(t);
ctx.ApiMethods.Add(new Commons.Entities.InboundApi.ApiMethod(
"Ingest",
"var x = SharedHelper(); return x;"));
await ctx.SaveChangesAsync();
}
var sessionId = await ExportWipeAndLoadAsync();
SemanticValidationException ex = default!;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
ex = await Assert.ThrowsAsync<SemanticValidationException>(() =>
importer.ApplyAsync(sessionId,
new List<ImportResolution>
{
new("Template", "Helper", ResolutionAction.Add, null),
new("ApiMethod", "Ingest", ResolutionAction.Add, null),
},
user: "bob"));
}
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, err => err.Contains("SharedHelper", StringComparison.Ordinal));
}
[Fact]
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
{