mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 08:37:41 +02:00
283 lines
9.1 KiB
Go
283 lines
9.1 KiB
Go
package security
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"cyberstrike-ai/internal/config"
|
|
"cyberstrike-ai/internal/mcp"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// setupTestExecutor 创建测试用的执行器
|
|
func setupTestExecutor(t *testing.T) (*Executor, *mcp.Server) {
|
|
logger := zap.NewNop()
|
|
mcpServer := mcp.NewServer(logger)
|
|
|
|
cfg := &config.SecurityConfig{
|
|
Tools: []config.ToolConfig{},
|
|
}
|
|
|
|
executor := NewExecutor(cfg, mcpServer, logger)
|
|
return executor, mcpServer
|
|
}
|
|
|
|
func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
|
|
executor, _ := setupTestExecutor(t)
|
|
|
|
ctx := context.Background()
|
|
args := map[string]interface{}{
|
|
"test": "value",
|
|
}
|
|
|
|
// 测试未知的内部工具类型
|
|
toolResult, err := executor.executeInternalTool(ctx, "unknown_tool", "internal:unknown_tool", args)
|
|
if err != nil {
|
|
t.Fatalf("执行内部工具失败: %v", err)
|
|
}
|
|
|
|
if !toolResult.IsError {
|
|
t.Fatal("未知的工具类型应该返回错误")
|
|
}
|
|
|
|
if !strings.Contains(toolResult.Content[0].Text, "未知的内部工具类型") {
|
|
t.Errorf("错误消息应该包含'未知的内部工具类型'")
|
|
}
|
|
}
|
|
|
|
func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T) {
|
|
executor, _ := setupTestExecutor(t)
|
|
// 子进程先向 stdout 写无换行字符再长时间 sleep;若与 echo $pid 共享管道且未重定向子进程 stdout,
|
|
// ReadString('\n') 会阻塞到子进程退出。后台包装须将子进程标准流与 PID 行分离。
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
defer cancel()
|
|
args := map[string]interface{}{
|
|
"command": `(sh -c 'printf x; sleep 120') &`,
|
|
"shell": "sh",
|
|
}
|
|
res, err := executor.executeSystemCommand(ctx, args)
|
|
if err != nil {
|
|
t.Fatalf("executeSystemCommand: %v", err)
|
|
}
|
|
if res == nil || res.IsError {
|
|
t.Fatalf("expected success, got %+v", res)
|
|
}
|
|
txt := res.Content[0].Text
|
|
if !strings.Contains(txt, "后台命令已启动") {
|
|
t.Fatalf("unexpected body: %q", txt)
|
|
}
|
|
}
|
|
|
|
func TestExecToolSoftWaitExposesPartialOutput(t *testing.T) {
|
|
executor, server := setupTestExecutor(t)
|
|
server.ConfigureToolWaitTimeoutSeconds(1)
|
|
mcp.RegisterExecutionControlTools(server, nil)
|
|
server.RegisterTool(mcp.Tool{Name: "exec", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
|
return executor.ExecuteTool(ctx, "exec", args)
|
|
})
|
|
|
|
result, executionID, err := server.CallTool(context.Background(), "exec", map[string]interface{}{
|
|
"command": "for i in 1 2 3 4; do echo partial-$i; sleep 0.3; done; sleep 5",
|
|
"shell": "sh",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CallTool exec: %v", err)
|
|
}
|
|
if executionID == "" || result == nil || !result.IsError {
|
|
t.Fatalf("expected soft wait timeout, id=%q result=%#v", executionID, result)
|
|
}
|
|
|
|
status, _, err := server.CallTool(context.Background(), "get_tool_execution", map[string]interface{}{
|
|
"execution_id": executionID,
|
|
"include_partial_output": true,
|
|
"partial_output_max_bytes": 4096,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("get_tool_execution: %v", err)
|
|
}
|
|
body := mcp.ToolResultPlainText(status)
|
|
if !strings.Contains(body, `"status": "running"`) {
|
|
t.Fatalf("expected running execution, got: %s", body)
|
|
}
|
|
if !strings.Contains(body, "partial-") || !strings.Contains(body, "partial_output") {
|
|
t.Fatalf("expected partial output in execution status, got: %s", body)
|
|
}
|
|
server.CancelToolExecution(executionID)
|
|
}
|
|
|
|
func TestExecuteSystemCommand_FailureFormat(t *testing.T) {
|
|
executor, _ := setupTestExecutor(t)
|
|
res, err := executor.executeSystemCommand(context.Background(), map[string]interface{}{
|
|
"command": "echo fail-msg >&2; exit 7",
|
|
"shell": "sh",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("executeSystemCommand: %v", err)
|
|
}
|
|
if res == nil || !res.IsError {
|
|
t.Fatalf("expected IsError, got %+v", res)
|
|
}
|
|
text := res.Content[0].Text
|
|
if text != FormatCommandFailureResult(7, "fail-msg\n") && text != FormatCommandFailureResult(7, "fail-msg") {
|
|
t.Fatalf("unexpected failure text: %q", text)
|
|
}
|
|
if !strings.Contains(text, "exit status 7") || !strings.Contains(text, "fail-msg") {
|
|
t.Fatalf("unexpected failure text: %q", text)
|
|
}
|
|
}
|
|
|
|
func TestExecuteSystemCommand_OutputIsSourceLimited(t *testing.T) {
|
|
executor, _ := setupTestExecutor(t)
|
|
spillRoot := t.TempDir()
|
|
executor.SetToolOutputMaxBytes(200)
|
|
executor.SetToolOutputSpillRoot(spillRoot)
|
|
ctx := mcp.WithMCPConversationID(context.Background(), "exec-spill")
|
|
res, err := executor.executeSystemCommand(ctx, map[string]interface{}{
|
|
"command": "i=0; while [ $i -lt 2000 ]; do printf 0123456789; i=$((i+1)); done",
|
|
"shell": "sh",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("executeSystemCommand: %v", err)
|
|
}
|
|
if res == nil || res.IsError {
|
|
t.Fatalf("expected success, got %+v", res)
|
|
}
|
|
text := res.Content[0].Text
|
|
if !strings.Contains(text, "<persisted-output>") || !strings.Contains(text, "Full output saved to:") {
|
|
t.Fatalf("missing persisted-output notice: %q", text)
|
|
}
|
|
if len(text) > 200 {
|
|
t.Fatalf("output exceeded hard limit: len=%d text=%q", len(text), text)
|
|
}
|
|
if strings.Contains(text, strings.Repeat("0123456789", 20)) {
|
|
t.Fatalf("output kept too much data: len=%d", len(text))
|
|
}
|
|
}
|
|
|
|
func TestExecuteSystemCommand_StreamingOutputIsSourceLimited(t *testing.T) {
|
|
executor, _ := setupTestExecutor(t)
|
|
spillRoot := t.TempDir()
|
|
executor.SetToolOutputMaxBytes(200)
|
|
executor.SetToolOutputSpillRoot(spillRoot)
|
|
var streamed strings.Builder
|
|
ctx := context.WithValue(context.Background(), ToolOutputCallbackCtxKey, ToolOutputCallback(func(chunk string) {
|
|
streamed.WriteString(chunk)
|
|
}))
|
|
ctx = mcp.WithMCPConversationID(ctx, "exec-stream-spill")
|
|
res, err := executor.executeSystemCommand(ctx, map[string]interface{}{
|
|
"command": "i=0; while [ $i -lt 2000 ]; do printf abcdefghij; i=$((i+1)); done",
|
|
"shell": "sh",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("executeSystemCommand: %v", err)
|
|
}
|
|
text := res.Content[0].Text
|
|
if !strings.Contains(text, "<persisted-output>") {
|
|
t.Fatalf("missing persisted-output notice: %q", text)
|
|
}
|
|
if len(text) > 200 {
|
|
t.Fatalf("returned output exceeded hard limit: len=%d text=%q", len(text), text)
|
|
}
|
|
// SSE only streams the bounded prefix; final agent-facing body is the spill notice.
|
|
if len(streamed.String()) > 200 {
|
|
t.Fatalf("streamed prefix exceeded hard limit: len=%d", len(streamed.String()))
|
|
}
|
|
if streamed.Len() == 0 {
|
|
t.Fatal("expected some streamed prefix before truncation")
|
|
}
|
|
if strings.Contains(text, strings.Repeat("abcdefghij", 50)) {
|
|
t.Fatalf("returned output kept too much raw data: len=%d", len(text))
|
|
}
|
|
}
|
|
|
|
func TestBuildCommandArgs_NmapSkipsEmptyOptionalFlags(t *testing.T) {
|
|
pos1 := 1
|
|
executor, _ := setupTestExecutor(t)
|
|
toolConfig := &config.ToolConfig{
|
|
Name: "nmap",
|
|
Command: "nmap",
|
|
Args: []string{"-sT", "-sV", "-sC"},
|
|
Parameters: []config.ParameterConfig{
|
|
{Name: "target", Type: "string", Required: true, Position: &pos1, Format: "positional"},
|
|
{Name: "ports", Type: "string", Flag: "-p", Format: "flag"},
|
|
{Name: "timing", Type: "string", Template: "-T{value}", Format: "template"},
|
|
{Name: "nse_scripts", Type: "string", Flag: "--script", Format: "flag"},
|
|
{Name: "os_detection", Type: "bool", Flag: "-O", Format: "flag", Default: false},
|
|
{Name: "aggressive", Type: "bool", Flag: "-A", Format: "flag", Default: false},
|
|
{Name: "scan_type", Type: "string", Format: "template", Template: "{value}"},
|
|
{Name: "additional_args", Type: "string", Format: "positional"},
|
|
},
|
|
}
|
|
|
|
args := map[string]interface{}{
|
|
"target": "110.52.223.114",
|
|
"ports": "21, 22, 80, 443",
|
|
"timing": "4",
|
|
"nse_scripts": "",
|
|
"scan_type": "",
|
|
"os_detection": false,
|
|
"aggressive": false,
|
|
"additional_args": "-Pn",
|
|
}
|
|
|
|
cmdArgs := executor.buildCommandArgs("nmap", toolConfig, args)
|
|
joined := strings.Join(cmdArgs, " ")
|
|
|
|
if strings.Contains(joined, "--script") {
|
|
t.Fatalf("empty nse_scripts must not emit --script, got: %v", cmdArgs)
|
|
}
|
|
if !strings.Contains(joined, "110.52.223.114") {
|
|
t.Fatalf("target missing from args: %v", cmdArgs)
|
|
}
|
|
// target 应出现在 -Pn 之前,避免被误当作 --script 的参数
|
|
pnIdx := indexOf(cmdArgs, "-Pn")
|
|
targetIdx := indexOf(cmdArgs, "110.52.223.114")
|
|
if pnIdx < 0 || targetIdx < 0 || targetIdx >= pnIdx {
|
|
t.Fatalf("expected target before -Pn, got: %v", cmdArgs)
|
|
}
|
|
}
|
|
|
|
func indexOf(slice []string, s string) int {
|
|
for i, v := range slice {
|
|
if v == s {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// TestCombinedOutputCancellable_ContextCancelKillsTree 验证 ctx 取消时能在数秒内结束(杀进程组,非挂死)。
|
|
func TestCombinedOutputCancellable_ContextCancelKillsTree(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("unix process group kill")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
cmd := exec.CommandContext(ctx, "sh", "-c", "sleep 300")
|
|
ConfigureShellCmdForAgentExecute(cmd)
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := combinedOutputCancellable(ctx, cmd)
|
|
done <- err
|
|
}()
|
|
|
|
time.Sleep(150 * time.Millisecond)
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("expected context cancel error")
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("combinedOutputCancellable did not return within 5s after context cancel")
|
|
}
|
|
}
|