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
@@ -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()
{