fix(security): bundle import runs the script trust gate — forbidden-API scripts rejected at import review, not at runtime

#05-T20. Bundle import is now the fifth ScriptTrustValidator delegating call
site. RunSemanticValidationAsync gains a Pass 0 that runs ScriptTrustValidator
.FindViolations over every non-Skip template / shared / ApiMethod script body
before name resolution; any violation is a HARD error for all kinds (a trust
verdict is authoritative, not the false-positive-prone name heuristic) and
short-circuits so it is never masked by a Pass-1 name error. DetectBlockersAsync
runs the same gate for preview parity, emitting entity-qualified Blocker rows.
Transport gains a direct ScriptAnalysis project reference. Docs: Component-
ScriptAnalysis (fifth call site) + Component-Transport updated.

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 02:18:12 -04:00
parent c60347c5e7
commit 523e670579
7 changed files with 197 additions and 5 deletions
@@ -426,6 +426,38 @@ public sealed class BundleImporterPreviewTests : IDisposable
&& i.BlockerReason.Contains("MissingHelper", StringComparison.Ordinal));
}
[Fact]
public async Task PreviewAsync_emits_Blocker_when_template_script_has_forbidden_api()
{
// #05-T20 — the script trust gate runs in preview too, so a forbidden-API
// script surfaces as a Blocker row (parity with the apply-time
// SemanticValidationException). Trust violations block regardless of kind.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Malicious") { Description = "forbidden" };
t.Scripts.Add(new TemplateScript("init", "System.Diagnostics.Process.Start(\"cmd\");"));
ctx.Templates.Add(t);
await ctx.SaveChangesAsync();
}
var bundleStream = await ExportTemplatesAsync();
var bytes = await StreamToBytes(bundleStream);
ImportPreview preview;
await using (var scope = _provider.CreateAsyncScope())
{
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
var session = await importer.LoadAsync(new MemoryStream(bytes), passphrase: null);
preview = await importer.PreviewAsync(session.SessionId);
}
Assert.Contains(preview.Items, i =>
i.Kind == ConflictKind.Blocker
&& i.BlockerReason is not null
&& i.BlockerReason.Contains("Process", StringComparison.Ordinal));
}
[Fact]
public async Task PreviewAsync_does_not_flag_opcua_tag_paths_in_DataSourceReference_as_blockers()
{