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:
+32
@@ -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()
|
||||
{
|
||||
|
||||
+75
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user