build-flow.api/tests/BuildFlow.Scripting.Tests/SandboxSemanticWhitelistTests.cs

154 lines
6.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using BuildFlow.Shared.Domain.Common;
using BuildFlow.Shared.Domain.Errors;
using BuildFlow.Shared.Domain.Exceptions;
using FluentAssertions;
using MediatR;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Scripting.Application.Services;
using Scripting.Domain.Entities.ScriptFunction;
using Scripting.Domain.Enums;
using Scripting.Infrastructure.Persistence;
using Xunit;
namespace BuildFlow.Scripting.Tests;
/// <summary>
/// 沙箱语义白名单测试:验证文本正则黑名单覆盖不到的绕过路径被语义分析拦截
/// (已导入命名空间下裸写类型名、反射成员入口、精确禁用类型),
/// 并验证正常语法糖(插值字符串、await、LINQ、JSON)不被误伤。
/// </summary>
public class SandboxSemanticWhitelistTests
{
private static (ScriptingDbContext db, ScriptExecutionService svc) CreateService()
{
var db = ScriptingTestDbFactory.Create();
var sender = Substitute.For<ISender>();
var currentUser = Substitute.For<ICurrentUserContext>();
currentUser.UserId.Returns("test-user");
currentUser.IPAddress.Returns("127.0.0.1");
var svc = new ScriptExecutionService(db, sender, currentUser);
return (db, svc);
}
private static ScriptFunctionEntity SeedScript(ScriptingDbContext db, string code)
{
var script = new ScriptFunctionEntity
{
Id = Guid.NewGuid(),
Name = $"sandbox-{Guid.NewGuid():N}",
DisplayName = "sandbox-test",
Code = code,
Status = ScriptStatusEnum.Published,
TimeoutMs = 5000,
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1",
};
db.Scripts.Add(script);
db.SaveChanges();
return script;
}
// ── 绕过路径:全部应被语义白名单拦截 ──────────────────────────────────
[Theory]
[InlineData("""Environment.GetEnvironmentVariable("PATH"); return 1;""", "System.Environment")]
[InlineData("var x = System.Environment.MachineName; return 1;", "System.Environment")]
[InlineData("_ = new Thread(() => { }); return 1;", "System.Threading.Thread")]
[InlineData("\"abc\".GetType(); return 1;", "GetType")]
[InlineData("var t = typeof(string); return 1;", "System.Type")]
[InlineData("var x = System.AppDomain.CurrentDomain.FriendlyName; return 1;", "System.AppDomain")]
[InlineData("var x = Activator.CreateInstance(typeof(int)); return 1;", "Activator")]
public async Task Execute_BypassAttempt_IsRejectedBySemanticWhitelist(string code, string expectedFragment)
{
var (db, svc) = CreateService();
var script = SeedScript(db, code);
await using var _ = db;
var act = () => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None);
var exception = (await act.Should().ThrowAsync<BusinessException>())
.WithMessage("*禁用 API*")
.Which;
exception.Code.Should().Be(ErrorCode.Business);
exception.Message.Should().Contain(expectedFragment);
}
// ── 正常语法糖:不应误伤 ─────────────────────────────────────────────
[Fact]
public async Task Execute_InterpolatedString_Succeeds()
{
var (db, svc) = CreateService();
// 插值字符串编译期依赖 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler,
// 白名单必须放行该命名空间(Unsafe 已单独精确封禁)
var script = SeedScript(db, """return $"x={Variables["a"]}"; """);
await using var _ = db;
var result = await svc.ExecuteByIdAsync(script.Id, """{"a":7}""", CancellationToken.None);
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
result.ResultJson.Should().Be("\"x=7\"");
}
[Fact]
public async Task Execute_AwaitAndLinq_Succeeds()
{
var (db, svc) = CreateService();
// await 编译期依赖 CompilerServices.TaskAwaiter;LINQ 依赖 System.Linq —— 均应在白名单内
var script = SeedScript(db,
"await Task.Delay(1, CancellationToken); var sum = new[] { 1L, 2L, 3L }.Sum(); return sum;");
await using var _ = db;
var result = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None);
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
result.ResultJson.Should().Be("6");
}
[Fact]
public async Task Execute_JsonProcessing_Succeeds()
{
var (db, svc) = CreateService();
var script = SeedScript(db,
"""var doc = JsonDocument.Parse(InputJson); return doc.RootElement.GetProperty("k").GetString();""");
await using var _ = db;
var result = await svc.ExecuteByIdAsync(script.Id, """{"k":"v"}""", CancellationToken.None);
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
result.ResultJson.Should().Be("\"v\"");
}
[Fact]
public async Task Execute_ScriptDefinedLocalClass_Succeeds()
{
var (db, svc) = CreateService();
// 脚本自定义类型位于全局命名空间,必须放行
var script = SeedScript(db, "class P { public long V { get; set; } } return new P { V = 5 }.V;");
await using var _ = db;
var result = await svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None);
result.Status.Should().Be(ScriptExecutionStatusEnum.Success);
result.ResultJson.Should().Be("5");
}
// ── 同一代码只校验一次(缓存命中后仍保持拦截语义由缓存键保证) ────────
[Fact]
public async Task Execute_ForbiddenCodeTwice_SecondAlsoRejected()
{
var (db, svc) = CreateService();
var script = SeedScript(db, "\"abc\".GetType(); return 1;");
await using var _ = db;
await FluentActions.Awaiting(() => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None))
.Should().ThrowAsync<BusinessException>();
await FluentActions.Awaiting(() => svc.ExecuteByIdAsync(script.Id, "{}", CancellationToken.None))
.Should().ThrowAsync<BusinessException>()
.WithMessage("*禁用 API*");
}
}