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()
{
@@ -292,6 +292,81 @@ public sealed class SemanticValidatorImportTests : IDisposable
}
}
[Fact]
public async Task Apply_TemplateScriptWithForbiddenApi_HardBlocks()
{
// #05-T20 — bundle import is the fifth script-trust call site. A template
// script that reaches a forbidden API (Process.Start) must be rejected at
// import review, NOT left to fail at runtime. Unlike the name-resolution
// heuristic, a trust verdict is authoritative → hard error for ALL kinds.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
var t = new Template("Malicious");
t.Scripts.Add(new Commons.Entities.Templates.TemplateScript(
"init",
"System.Diagnostics.Process.Start(\"cmd\");"));
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", "Malicious", ResolutionAction.Add, null),
},
user: "bob"));
}
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
Assert.False(await ctx.Templates.AnyAsync(t => t.Name == "Malicious"));
}
}
[Fact]
public async Task Apply_ApiMethodWithForbiddenApi_HardBlocks()
{
// #05-T20 — same trust gate over ApiMethod script bodies.
await using (var scope = _provider.CreateAsyncScope())
{
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
ctx.ApiMethods.Add(new Commons.Entities.InboundApi.ApiMethod(
"Ingest",
"System.Diagnostics.Process.Start(\"cmd\"); return 1;"));
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("ApiMethod", "Ingest", ResolutionAction.Add, null),
},
user: "bob"));
}
Assert.NotEmpty(ex.Errors);
Assert.Contains(ex.Errors, err => err.Contains("Process", StringComparison.Ordinal));
}
[Fact]
public async Task SemanticValidator_catches_alarm_trigger_type_mismatch_at_import()
{