Add files via upload

This commit is contained in:
公明
2026-08-15 01:57:26 +08:00
committed by GitHub
parent f564421b4d
commit 7f8093f8b9
52 changed files with 14151 additions and 0 deletions
+266
View File
@@ -0,0 +1,266 @@
package security
import (
"database/sql"
"errors"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/database"
"github.com/google/uuid"
)
// Predefined errors for authentication operations.
var (
ErrInvalidPassword = errors.New("invalid password")
)
// Session represents an authenticated user session.
type Session struct {
Token string
ExpiresAt time.Time
UserID string
Username string
DisplayName string
Roles []string
Permissions map[string]bool
PermissionScopes map[string]string
Scope string
}
// AuthManager manages password-based authentication and session lifecycle.
type AuthManager struct {
sessionDuration time.Duration
db *database.DB
mu sync.RWMutex
sessions map[string]Session
}
// NewAuthManager creates a new AuthManager instance.
func NewAuthManager(sessionDurationHours int) *AuthManager {
if sessionDurationHours <= 0 {
sessionDurationHours = 12
}
return &AuthManager{
sessionDuration: time.Duration(sessionDurationHours) * time.Hour,
sessions: make(map[string]Session),
}
}
// AttachRBACStore enables multi-user RBAC authentication. When no users exist yet,
// it bootstraps the built-in admin account and returns the generated initial password.
func (a *AuthManager) AttachRBACStore(db *database.DB) (generatedAdminPassword string, err error) {
if db == nil {
return "", errors.New("database is required for authentication")
}
needsAdminPassword, err := db.RBACNeedsAdminPassword()
if err != nil {
return "", err
}
adminPasswordHash := ""
if needsAdminPassword {
generatedAdminPassword, err = GenerateStrongPassword(24)
if err != nil {
return "", err
}
adminPasswordHash, err = HashPassword(generatedAdminPassword)
if err != nil {
return "", err
}
}
if err := db.BootstrapRBAC(adminPasswordHash, PermissionCatalog); err != nil {
return "", err
}
a.mu.Lock()
a.db = db
a.mu.Unlock()
return generatedAdminPassword, nil
}
// Authenticate validates the password and creates a new session.
func (a *AuthManager) Authenticate(username, password string) (string, time.Time, error) {
session, err := a.authenticateSession(username, password)
if err != nil {
return "", time.Time{}, err
}
a.mu.Lock()
a.sessions[session.Token] = session
a.mu.Unlock()
return session.Token, session.ExpiresAt, nil
}
func (a *AuthManager) authenticateSession(username, password string) (Session, error) {
token := uuid.NewString()
expiresAt := time.Now().Add(a.sessionDuration)
a.mu.RLock()
db := a.db
a.mu.RUnlock()
if db == nil {
return Session{}, errors.New("authentication store is not configured")
}
username = strings.TrimSpace(strings.ToLower(username))
if username == "" {
username = "admin"
}
user, err := db.GetRBACUserByUsername(username)
if err != nil {
if err == sql.ErrNoRows {
return Session{}, ErrInvalidPassword
}
return Session{}, err
}
if !user.Enabled || !VerifyPasswordHash(password, user.PasswordHash) {
return Session{}, ErrInvalidPassword
}
access, err := db.ResolveRBACAccess(user.ID)
if err != nil {
return Session{}, err
}
roleIDs := make([]string, 0, len(access.Roles))
for _, role := range access.Roles {
roleIDs = append(roleIDs, role.ID)
}
return Session{
Token: token,
ExpiresAt: expiresAt,
UserID: user.ID,
Username: user.Username,
DisplayName: user.DisplayName,
Roles: roleIDs,
Permissions: access.Permissions,
PermissionScopes: access.PermissionScopes,
Scope: access.Scope,
}, nil
}
func (s Session) ScopeFor(permission string) string {
if scope := strings.TrimSpace(s.PermissionScopes[strings.TrimSpace(permission)]); scope != "" {
return scope
}
return strings.TrimSpace(s.Scope)
}
// ValidateToken checks whether the provided token is still valid.
func (a *AuthManager) ValidateToken(token string) (Session, bool) {
if strings.TrimSpace(token) == "" {
return Session{}, false
}
a.mu.RLock()
session, ok := a.sessions[token]
a.mu.RUnlock()
if !ok {
return Session{}, false
}
if time.Now().After(session.ExpiresAt) {
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
return Session{}, false
}
return session, true
}
// CheckPassword verifies whether the provided password matches the current password.
func (a *AuthManager) CheckPassword(password string) bool {
return a.CheckUserPassword("admin", password)
}
// CheckUserPassword verifies whether the provided password matches a user.
func (a *AuthManager) CheckUserPassword(username, password string) bool {
a.mu.RLock()
db := a.db
a.mu.RUnlock()
if db == nil {
return false
}
user, err := db.GetRBACUserByUsername(username)
if err != nil {
return false
}
return VerifyPasswordHash(password, user.PasswordHash)
}
func (a *AuthManager) UpdateUserPassword(userID, password string) error {
password = strings.TrimSpace(password)
if password == "" {
return errors.New("auth password must be configured")
}
hash, err := HashPassword(password)
if err != nil {
return err
}
a.mu.RLock()
db := a.db
a.mu.RUnlock()
if db == nil {
return errors.New("authentication store is not configured")
}
if err := db.UpdateRBACUserPassword(userID, hash); err != nil {
return err
}
a.mu.Lock()
for token, session := range a.sessions {
if session.UserID == userID {
delete(a.sessions, token)
}
}
a.mu.Unlock()
return nil
}
// RevokeToken invalidates the specified token.
func (a *AuthManager) RevokeToken(token string) {
if strings.TrimSpace(token) == "" {
return
}
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
func (a *AuthManager) RevokeUserSessions(userID string) {
userID = strings.TrimSpace(userID)
if userID == "" {
return
}
a.mu.Lock()
for token, session := range a.sessions {
if session.UserID == userID {
delete(a.sessions, token)
}
}
a.mu.Unlock()
}
func (a *AuthManager) RevokeAllSessions() {
a.mu.Lock()
a.sessions = make(map[string]Session)
a.mu.Unlock()
}
// SessionDurationHours returns the configured session duration in hours.
func (a *AuthManager) SessionDurationHours() int {
return int(a.sessionDuration / time.Hour)
}
func allPermissions() map[string]bool {
out := make(map[string]bool, len(PermissionCatalog))
for key := range PermissionCatalog {
out[key] = true
}
return out
}
@@ -0,0 +1,38 @@
package security
import (
"path/filepath"
"testing"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
func TestAttachRBACStoreBootstrapsAdminPassword(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "auth-bootstrap.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
manager := NewAuthManager(12)
generated, err := manager.AttachRBACStore(db)
if err != nil {
t.Fatalf("AttachRBACStore: %v", err)
}
if generated == "" {
t.Fatal("expected generated admin password on first bootstrap")
}
if !manager.CheckUserPassword("admin", generated) {
t.Fatal("generated password should authenticate admin")
}
second, err := manager.AttachRBACStore(db)
if err != nil {
t.Fatalf("AttachRBACStore second call: %v", err)
}
if second != "" {
t.Fatalf("expected no password on second bootstrap, got %q", second)
}
}
+94
View File
@@ -0,0 +1,94 @@
package security
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestAuthManagerAuthenticatesCreatedRBACUser(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "auth-rbac.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
manager := NewAuthManager(12)
if _, err := manager.AttachRBACStore(db); err != nil {
t.Fatalf("AttachRBACStore: %v", err)
}
hash, err := HashPassword("operator-secret")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
user, err := db.CreateRBACUser("operator1", "Operator One", hash, true, []string{database.RBACSystemRoleViewer})
if err != nil {
t.Fatalf("CreateRBACUser: %v", err)
}
token, _, err := manager.Authenticate("operator1", "operator-secret")
if err != nil {
t.Fatalf("Authenticate created user: %v", err)
}
session, ok := manager.ValidateToken(token)
if !ok {
t.Fatalf("expected created user session to validate")
}
if session.UserID != user.ID || session.Username != "operator1" {
t.Fatalf("session user = %s/%s, want %s/operator1", session.UserID, session.Username, user.ID)
}
if !session.Permissions["auth:self"] || !session.Permissions["chat:read"] {
t.Fatalf("expected viewer permissions in session, got %#v", session.Permissions)
}
if _, _, err := manager.Authenticate("", "operator-secret"); err == nil {
t.Fatalf("empty username must not authenticate non-admin user")
}
router := gin.New()
router.Use(AuthMiddleware(manager))
router.GET("/principal", func(c *gin.Context) {
principal, ok := authctx.PrincipalFromContext(c.Request.Context())
if !ok || principal.UserID != user.ID || !principal.HasPermission("chat:read") || principal.ScopeFor("chat:read") != database.RBACScopeAssigned {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusNoContent)
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/principal", nil)
req.Header.Set("Authorization", "Bearer "+token)
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("principal propagation status = %d", w.Code)
}
}
func TestQueryTokenOnlyAllowedForSSEAndWebSocketGET(t *testing.T) {
requestToken := func(method, accept, upgrade string) string {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(method, "/api/test?token=secret", nil)
c.Request.Header.Set("Accept", accept)
c.Request.Header.Set("Upgrade", upgrade)
return extractTokenFromRequest(c)
}
if got := requestToken(http.MethodGet, "application/json", ""); got != "" {
t.Fatalf("ordinary GET accepted query token %q", got)
}
if got := requestToken(http.MethodPost, "text/event-stream", ""); got != "" {
t.Fatalf("POST accepted query token %q", got)
}
if got := requestToken(http.MethodGet, "text/event-stream", ""); got != "secret" {
t.Fatalf("SSE token = %q", got)
}
if got := requestToken(http.MethodGet, "", "websocket"); got != "secret" {
t.Fatalf("WebSocket token = %q", got)
}
}
+151
View File
@@ -0,0 +1,151 @@
package security
import (
"net/http"
"strings"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
)
const (
ContextAuthTokenKey = "authToken"
ContextSessionExpiry = "authSessionExpiry"
ContextUserIDKey = "authUserID"
ContextUsernameKey = "authUsername"
ContextUserScopeKey = "authUserScope"
ContextSessionKey = "authSession"
)
// AuthMiddleware enforces authentication on protected routes.
func AuthMiddleware(manager *AuthManager) gin.HandlerFunc {
return func(c *gin.Context) {
token := extractTokenFromRequest(c)
session, ok := manager.ValidateToken(token)
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "未授权访问,请先登录",
})
return
}
c.Set(ContextAuthTokenKey, session.Token)
c.Set(ContextSessionExpiry, session.ExpiresAt)
c.Set(ContextUserIDKey, session.UserID)
c.Set(ContextUsernameKey, session.Username)
c.Set(ContextUserScopeKey, session.Scope)
c.Set(ContextSessionKey, session)
// Gin context values do not survive into Agent/MCP/background contexts.
// Attach an immutable principal to the request context as the canonical
// identity for every downstream execution layer.
principal := authctx.NewPrincipalWithScopes(session.UserID, session.Username, session.Scope, session.Permissions, session.PermissionScopes)
c.Request = c.Request.WithContext(authctx.WithPrincipal(c.Request.Context(), principal))
c.Next()
}
}
func RequirePermission(permission string) gin.HandlerFunc {
permission = strings.TrimSpace(permission)
return func(c *gin.Context) {
if permission == "" || SessionHasPermission(c, permission) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "权限不足",
"permission": permission,
})
}
}
func RequireAnyPermission(permissions ...string) gin.HandlerFunc {
return func(c *gin.Context) {
for _, permission := range permissions {
if SessionHasPermission(c, permission) {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "权限不足",
"permissions": permissions,
})
}
}
func RequireResourcePermission(db *database.DB, permission, resourceType, paramName string) gin.HandlerFunc {
return func(c *gin.Context) {
if !SessionHasPermission(c, permission) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "权限不足",
"permission": permission,
})
return
}
if db == nil {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "资源鉴权服务不可用"})
return
}
resourceID := strings.TrimSpace(c.Param(paramName))
if resourceID == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "资源 ID 不能为空"})
return
}
session, ok := CurrentSession(c)
if !ok || !db.UserCanAccessResource(session.UserID, session.Scope, resourceType, resourceID) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "无权访问该资源",
"resource_type": resourceType,
"resource_id": resourceID,
})
return
}
c.Next()
}
}
func CurrentSession(c *gin.Context) (Session, bool) {
if c == nil {
return Session{}, false
}
v, ok := c.Get(ContextSessionKey)
if !ok {
return Session{}, false
}
session, ok := v.(Session)
return session, ok
}
func SessionHasPermission(c *gin.Context, permission string) bool {
session, ok := CurrentSession(c)
if !ok {
return false
}
return session.Permissions[permission]
}
func extractTokenFromRequest(c *gin.Context) string {
authHeader := c.GetHeader("Authorization")
if authHeader != "" {
if len(authHeader) > 7 && strings.EqualFold(authHeader[0:7], "Bearer ") {
return strings.TrimSpace(authHeader[7:])
}
return strings.TrimSpace(authHeader)
}
if token := c.Query("token"); token != "" && c.Request.Method == http.MethodGet {
acceptsSSE := strings.Contains(strings.ToLower(c.GetHeader("Accept")), "text/event-stream")
upgradesWebSocket := strings.EqualFold(strings.TrimSpace(c.GetHeader("Upgrade")), "websocket")
if acceptsSSE || upgradesWebSocket {
return strings.TrimSpace(token)
}
}
if cookie, err := c.Cookie("auth_token"); err == nil {
return strings.TrimSpace(cookie)
}
return ""
}
@@ -0,0 +1,56 @@
package security
import (
"errors"
"fmt"
"os/exec"
"strings"
)
// FormatCommandFailureResult 与 exec 工具 ToolResult 文案一致(不含 ToolErrorPrefix)。
func FormatCommandFailureResult(exitCode int, output string) string {
output = strings.TrimSpace(output)
errMsg := fmt.Sprintf("exit status %d", exitCode)
if output == "" {
return fmt.Sprintf("命令执行失败: %s", errMsg)
}
if strings.HasPrefix(output, "命令执行失败:") {
return output
}
return fmt.Sprintf("命令执行失败: %s\n输出: %s", errMsg, output)
}
// FormatCommandFailureFromErr 根据 exec/execute 返回的 error 生成统一失败文案(IsError 正文)。
func FormatCommandFailureFromErr(err error, output string) string {
if err == nil {
return strings.TrimSpace(output)
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
return FormatCommandFailureResult(exitError.ExitCode(), output)
}
output = strings.TrimSpace(output)
if output == "" {
return fmt.Sprintf("命令执行失败: %v", err)
}
if strings.HasPrefix(output, "命令执行失败:") {
return output
}
return fmt.Sprintf("命令执行失败: %v\n输出: %s", err, output)
}
// ExecuteFailureStatusLine 流式 execute 结束时追加的单行状态(输出正文已在流中推送过)。
func ExecuteFailureStatusLine(exitCode int) string {
return fmt.Sprintf("\n命令执行失败: exit status %d", exitCode)
}
// IsCommandFailureResult 判断工具结果正文是否表示命令非零退出(用于 execute / exec 对齐 isError)。
func IsCommandFailureResult(content string) bool {
return strings.Contains(content, "命令执行失败:")
}
// IsLegacyShellExitNoise 过滤旧版 shell 流中冗余的 exit code 行。
func IsLegacyShellExitNoise(s string) bool {
trimmed := strings.TrimSpace(s)
return strings.HasPrefix(trimmed, "command exited with non-zero code ")
}
@@ -0,0 +1,54 @@
package security
import (
"errors"
"os/exec"
"strings"
"testing"
)
func TestFormatCommandFailureResult(t *testing.T) {
got := FormatCommandFailureResult(1, "sudo: password required")
want := "命令执行失败: exit status 1\n输出: sudo: password required"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
if FormatCommandFailureResult(2, "") != "命令执行失败: exit status 2" {
t.Fatal("empty output format")
}
if FormatCommandFailureResult(1, "命令执行失败: exit status 1") != "命令执行失败: exit status 1" {
t.Fatal("should not double-wrap")
}
}
func TestIsCommandFailureResult(t *testing.T) {
if !IsCommandFailureResult("sudo: err\n命令执行失败: exit status 1") {
t.Fatal("expected true")
}
if IsCommandFailureResult("sudo: err only") {
t.Fatal("expected false")
}
}
func TestFormatCommandFailureFromErr(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 42")
err := cmd.Run()
got := FormatCommandFailureFromErr(err, "oops")
if got != "命令执行失败: exit status 42\n输出: oops" {
t.Fatalf("got %q", got)
}
timeoutErr := errors.New("shell inactivity timeout (300s)")
got2 := FormatCommandFailureFromErr(timeoutErr, "already timed out")
if !strings.Contains(got2, "shell inactivity timeout") || !strings.Contains(got2, "already timed out") {
t.Fatalf("got %q", got2)
}
}
func TestIsLegacyShellExitNoise(t *testing.T) {
if !IsLegacyShellExitNoise("command exited with non-zero code 1\n") {
t.Fatal("expected legacy noise")
}
if IsLegacyShellExitNoise("sudo: failed") {
t.Fatal("unexpected noise")
}
}
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
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")
}
}
+24
View File
@@ -0,0 +1,24 @@
package security
import (
"crypto/rand"
"encoding/base64"
)
// GenerateStrongPassword returns a URL-safe random password of the given length.
func GenerateStrongPassword(length int) (string, error) {
if length <= 0 {
length = 24
}
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
return "", err
}
password := base64.RawURLEncoding.EncodeToString(randomBytes)
if len(password) > length {
password = password[:length]
}
return password, nil
}
+41
View File
@@ -0,0 +1,41 @@
//go:build !windows
package security
import (
"os/exec"
"syscall"
)
// prepareShellCmdSession 让 shell 子进程在独立会话中运行,便于超时/取消时整组 SIGKILL(含子进程)。
func prepareShellCmdSession(cmd *exec.Cmd) error {
if cmd == nil {
return nil
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setsid = true
return nil
}
// terminateProcessGroup 对 rootPID 对应进程组发 SIGKILLrootPID 为 0 时回退到 cmd.Process.Pid。
func terminateProcessGroup(rootPID int, cmd *exec.Cmd) {
pid := rootPID
if pid <= 0 && cmd != nil && cmd.Process != nil {
pid = cmd.Process.Pid
}
if pid <= 0 {
return
}
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
}
// terminateCmdTree 尽力终止 cmd 及其进程组(Unix 下 Setsid 后 PGID == 首进程 PID)。
func terminateCmdTree(cmd *exec.Cmd) {
terminateProcessGroup(0, cmd)
}
+43
View File
@@ -0,0 +1,43 @@
//go:build windows
package security
import (
"os/exec"
"strconv"
"syscall"
)
func prepareShellCmdSession(cmd *exec.Cmd) error {
if cmd == nil {
return nil
}
// 独立进程组,便于 taskkill /T 终止整棵子进程树。
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.CreationFlags = syscall.CREATE_NEW_PROCESS_GROUP
return nil
}
// terminateProcessGroup 使用 taskkill /F /T 终止进程及其子进程;rootPID 为 0 时回退到 cmd.Process.Pid。
func terminateProcessGroup(rootPID int, cmd *exec.Cmd) {
pid := rootPID
if pid <= 0 && cmd != nil && cmd.Process != nil {
pid = cmd.Process.Pid
}
if pid <= 0 {
return
}
tk := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid))
if err := tk.Run(); err != nil {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
}
// terminateCmdTree 使用 taskkill /F /T 终止进程及其子进程(Windows 上 Process.Kill 无法保证杀掉 python 等孙进程)。
func terminateCmdTree(cmd *exec.Cmd) {
terminateProcessGroup(0, cmd)
}
+81
View File
@@ -0,0 +1,81 @@
package security
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// rateLimitEntry 记录某个 IP 的请求窗口信息
type rateLimitEntry struct {
count int
windowAt time.Time
}
// RateLimiter 基于 IP 的滑动窗口速率限制器
type RateLimiter struct {
mu sync.Mutex
entries map[string]*rateLimitEntry
limit int // 窗口内允许的最大请求数
window time.Duration // 窗口时长
}
// NewRateLimiter 创建速率限制器
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
entries: make(map[string]*rateLimitEntry),
limit: limit,
window: window,
}
// 后台定期清理过期条目,防止内存泄漏
go rl.cleanup()
return rl
}
// cleanup 每分钟清理一次过期条目
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
rl.mu.Lock()
now := time.Now()
for ip, entry := range rl.entries {
if now.Sub(entry.windowAt) > rl.window {
delete(rl.entries, ip)
}
}
rl.mu.Unlock()
}
}
// allow 检查指定 IP 是否允许通过
func (rl *RateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
entry, ok := rl.entries[ip]
if !ok || now.Sub(entry.windowAt) > rl.window {
rl.entries[ip] = &rateLimitEntry{count: 1, windowAt: now}
return true
}
entry.count++
return entry.count <= rl.limit
}
// RateLimitMiddleware 返回 Gin 中间件,对超限请求返回 429
func RateLimitMiddleware(rl *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !rl.allow(ip) {
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded, please try again later",
})
return
}
c.Next()
}
}
+119
View File
@@ -0,0 +1,119 @@
package security
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"strings"
"golang.org/x/crypto/bcrypt"
)
// Platform permissions use module:action naming. They are intentionally
// separate from AI testing roles under roles/.
var PermissionCatalog = map[string]string{
"auth:self": "Manage own session and password",
"dashboard:read": "View dashboard summaries",
"chat:read": "View conversations",
"chat:write": "Create and update conversations",
"chat:delete": "Delete conversations and turns",
"agent:execute": "Run AI agents and workflows",
"agent:local-execute": "Use local filesystem, shell, and configured command tools from an agent",
"hitl:read": "View HITL queues and logs",
"hitl:write": "Approve, dismiss, and configure HITL",
"tasks:read": "View task queues",
"tasks:write": "Create and run task queues",
"tasks:delete": "Delete task queues",
"project:read": "View projects and project facts",
"project:write": "Create and update projects and facts",
"project:delete": "Delete projects and facts",
"vulnerability:read": "View vulnerabilities",
"vulnerability:write": "Create and update vulnerabilities",
"vulnerability:delete": "Delete vulnerabilities",
"asset:read": "View managed assets and asset summaries",
"asset:write": "Create, import, and update assets",
"asset:delete": "Delete managed assets",
"webshell:read": "View WebShell connections",
"webshell:write": "Manage and use WebShell connections",
"webshell:delete": "Delete WebShell connections",
"c2:read": "View C2 listeners, sessions, tasks, events, and profiles",
"c2:write": "Operate C2 listeners, sessions, tasks, payloads, files, and profiles",
"c2:delete": "Delete C2 objects",
"mcp:read": "View MCP status and external MCP configuration",
"mcp:execute": "Invoke the authenticated MCP endpoint",
"mcp:external:execute": "Invoke tools exposed by configured external MCP servers",
"mcp:write": "Manage external MCP server configuration and lifecycle",
"knowledge:read": "View knowledge base and retrieval logs",
"knowledge:write": "Create, update, index, and scan knowledge base",
"knowledge:delete": "Delete knowledge items and retrieval logs",
"skills:read": "View skills and skill stats",
"skills:write": "Create and update skills",
"skills:delete": "Delete skills and stats",
"agents:read": "View markdown agents",
"agents:write": "Create and update markdown agents",
"agents:delete": "Delete markdown agents",
"roles:read": "View AI testing roles",
"roles:write": "Create and update AI testing roles",
"roles:delete": "Delete AI testing roles",
"workflow:read": "View workflow definitions and runs",
"workflow:execute": "Validate, dry-run, and resume authorized workflow runs",
"workflow:write": "Create and update workflow definitions",
"workflow:delete": "Delete workflows",
"config:read": "View system configuration",
"config:write": "Update and apply system configuration",
"terminal:execute": "Run terminal commands",
"audit:read": "View and export audit logs",
"audit:delete": "Delete audit logs",
"rbac:read": "View users, platform roles, permissions, and assignments",
"rbac:write": "Manage users, platform roles, permissions, and assignments",
"notification:read": "View notifications",
"notification:write": "Mark notifications as read",
"robot:read": "View robot binding status",
"robot:write": "Manage robot bindings and test robot callbacks",
"files:read": "View chat uploads",
"files:write": "Upload, edit, and rename chat files",
"files:delete": "Delete chat files",
"attackchain:read": "View attack chains",
"attackchain:write": "Regenerate attack chains",
"fofa:execute": "Run FOFA searches and query parsing",
"openapi:read": "Read OpenAPI aggregation results",
"group:read": "View conversation groups",
"group:write": "Create and update conversation groups",
"group:delete": "Delete conversation groups",
"monitor:read": "View execution monitor",
"monitor:write": "Cancel monitor executions",
"monitor:delete": "Delete monitor executions",
}
func HashPassword(password string) (string, error) {
password = strings.TrimSpace(password)
if password == "" {
return "", fmt.Errorf("password is empty")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func VerifyPasswordHash(password, encoded string) bool {
if strings.HasPrefix(encoded, "$2a$") || strings.HasPrefix(encoded, "$2b$") || strings.HasPrefix(encoded, "$2y$") {
return bcrypt.CompareHashAndPassword([]byte(encoded), []byte(strings.TrimSpace(password))) == nil
}
parts := strings.Split(encoded, "$")
if len(parts) != 3 || parts[0] != "sha256" {
return false
}
salt, err := hex.DecodeString(parts[1])
if err != nil {
return false
}
expected, err := hex.DecodeString(parts[2])
if err != nil {
return false
}
sum := sha256.Sum256(append(salt, []byte(strings.TrimSpace(password))...))
return subtle.ConstantTimeCompare(sum[:], expected) == 1
}
+282
View File
@@ -0,0 +1,282 @@
package security
import (
"net/http"
"strings"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
)
// RBACMiddleware maps protected API routes to platform permissions. It keeps
// enforcement centralized so route declarations stay readable.
func RBACMiddleware(db *database.DB) gin.HandlerFunc {
return RBACMiddlewareWithDenyHook(db, nil)
}
type RBACDenyHook func(c *gin.Context, reason, permission string)
func RBACMiddlewareWithDenyHook(db *database.DB, denyHook RBACDenyHook) gin.HandlerFunc {
return func(c *gin.Context) {
permission := permissionForRequest(c.Request.Method, c.FullPath())
if permission == "" {
if denyHook != nil {
denyHook(c, "unmapped_route", "")
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "未配置访问权限",
})
return
}
permission, allowed := sessionHasRoutePermission(c, c.Request.Method, c.FullPath())
if !allowed {
if denyHook != nil {
denyHook(c, "permission_denied", permission)
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "权限不足",
"permission": permission,
})
return
}
// Bind the scope of the permission authorizing this request. Scope is
// permission-specific; using the user's broadest role scope here would
// let an unrelated global read role widen a write permission.
session, _ := CurrentSession(c)
session.Scope = session.ScopeFor(permission)
c.Set(ContextSessionKey, session)
c.Set(ContextUserScopeKey, session.Scope)
if db != nil && !resourceAllowed(c, db) {
if denyHook != nil {
denyHook(c, "resource_denied", permission)
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
c.Next()
}
}
func sessionHasRoutePermission(c *gin.Context, method, fullPath string) (string, bool) {
path := strings.TrimPrefix(fullPath, "/api")
if alts := permissionAlternativesForRequest(method, path); len(alts) > 0 {
for _, permission := range alts {
if SessionHasPermission(c, permission) {
return permission, true
}
}
return alts[0], false
}
permission := permissionForRequest(method, fullPath)
if permission == "" {
return "", false
}
return permission, SessionHasPermission(c, permission)
}
func permissionAlternativesForRequest(method, path string) []string {
if method != http.MethodGet && method != http.MethodHead {
return nil
}
switch {
case strings.HasPrefix(path, "/config/tools"):
// MCP 管理页只需 mcp:read;系统设置页仍可用 config:read 访问同一接口。
return []string{"mcp:read", "config:read"}
default:
return nil
}
}
func permissionForRequest(method, fullPath string) string {
path := strings.TrimPrefix(fullPath, "/api")
switch {
case path == "/rbac/me":
return "auth:self"
case path == "/rbac/resources":
// The picker enumerates resource names and IDs and is only needed by
// administrators who can actually create assignments.
return "rbac:write"
case strings.HasPrefix(path, "/rbac"):
if method == http.MethodGet {
return "rbac:read"
}
return "rbac:write"
case strings.HasPrefix(path, "/robot/wechat/status"):
return "robot:read"
case strings.HasPrefix(path, "/robot"):
return "robot:write"
case strings.HasPrefix(path, "/eino-agent"), strings.HasPrefix(path, "/multi-agent"):
if strings.Contains(path, "/markdown-agents") {
return crudPermission(method, "agents")
}
return "agent:execute"
case strings.HasPrefix(path, "/hitl"):
if method == http.MethodGet || method == http.MethodHead {
return "hitl:read"
}
return "hitl:write"
case strings.HasPrefix(path, "/agent-loop"), strings.HasPrefix(path, "/batch-tasks"):
return crudPermission(method, "tasks")
case strings.HasPrefix(path, "/conversations"), strings.HasPrefix(path, "/messages"), strings.HasPrefix(path, "/process-details"):
return crudPermission(method, "chat")
case strings.HasPrefix(path, "/groups"):
return crudPermission(method, "group")
case strings.HasPrefix(path, "/monitor"):
return crudPermission(method, "monitor")
case strings.HasPrefix(path, "/notifications"):
if method == http.MethodGet {
return "notification:read"
}
return "notification:write"
case strings.HasPrefix(path, "/config"):
return crudPermission(method, "config")
case strings.HasPrefix(path, "/terminal"):
return "terminal:execute"
case strings.HasPrefix(path, "/audit"):
return crudPermission(method, "audit")
case path == "/mcp":
return "mcp:execute"
case strings.HasPrefix(path, "/external-mcp"):
if method == http.MethodGet || method == http.MethodHead {
return "mcp:read"
}
return "mcp:write"
case strings.HasPrefix(path, "/attack-chain"):
return crudPermission(method, "attackchain")
case strings.HasPrefix(path, "/knowledge"):
if path == "/knowledge/search" {
return "knowledge:read"
}
return crudPermission(method, "knowledge")
case strings.HasPrefix(path, "/vulnerabilities"):
return crudPermission(method, "vulnerability")
case path == "/assets/batch-delete", path == "/assets/merge":
return "asset:delete"
case strings.HasPrefix(path, "/assets"):
return crudPermission(method, "asset")
case strings.HasPrefix(path, "/vulnerability-alerts"):
// This endpoint only changes the authenticated user's own preference.
return "vulnerability:read"
case strings.HasPrefix(path, "/projects"):
return crudPermission(method, "project")
case strings.HasPrefix(path, "/webshell"):
return crudPermission(method, "webshell")
case strings.HasPrefix(path, "/c2"):
return crudPermission(method, "c2")
case strings.HasPrefix(path, "/chat-uploads"):
return crudPermission(method, "files")
case strings.HasPrefix(path, "/roles"):
return crudPermission(method, "roles")
case path == "/workflows/:id/package":
return "workflow:read"
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
return "workflow:write"
case path == "/workflows/generate-draft":
return "workflow:write"
case strings.HasPrefix(path, "/workflows"):
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
return "workflow:execute"
}
return crudPermission(method, "workflow")
case strings.HasPrefix(path, "/skills"):
return crudPermission(method, "skills")
case strings.HasPrefix(path, "/openapi"):
return "openapi:read"
case strings.HasPrefix(path, "/fofa"):
return "fofa:execute"
default:
return ""
}
}
func crudPermission(method, module string) string {
switch method {
case http.MethodGet, http.MethodHead:
return module + ":read"
case http.MethodDelete:
return module + ":delete"
default:
return module + ":write"
}
}
func resourceAllowed(c *gin.Context, db *database.DB) bool {
session, ok := CurrentSession(c)
if !ok || session.Scope == database.RBACScopeAll {
return ok
}
path := strings.TrimPrefix(c.FullPath(), "/api")
switch {
case path == "/monitor/stats", path == "/monitor/calls-timeline":
// These APIs currently operate on process-global state. Until every MCP
// invocation and persisted execution record carries an immutable owner,
// allowing an assigned/own-scoped session would be a cross-user bypass.
return session.Scope == database.RBACScopeAll
case strings.HasPrefix(path, "/c2/profiles") && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case (strings.HasPrefix(path, "/hitl/tool-whitelist") || strings.HasPrefix(path, "/hitl/default-reviewer") || strings.HasPrefix(path, "/hitl/audit-strategy")) && c.Request.Method != http.MethodGet:
return session.Scope == database.RBACScopeAll
case isMutationMethod(c.Request.Method) && isProcessGlobalMutationPath(path):
// These definitions/configurations are shared by every user and do not
// carry owners. A module write permission with assigned/own scope must
// not silently become a process-global administrative capability.
return session.Scope == database.RBACScopeAll
case strings.HasPrefix(path, "/projects/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "project", c.Param("id"))
case strings.HasPrefix(path, "/conversations/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "conversation", c.Param("id"))
case strings.HasPrefix(path, "/messages/:id/process-details"):
return db.UserCanAccessMessage(session.UserID, session.Scope, c.Param("id"))
case strings.HasPrefix(path, "/process-details/:id"):
return db.UserCanAccessProcessDetail(session.UserID, session.Scope, c.Param("id"))
case strings.HasPrefix(path, "/attack-chain/:conversationId"):
return db.UserCanAccessResource(session.UserID, session.Scope, "conversation", c.Param("conversationId"))
case strings.HasPrefix(path, "/webshell/connections/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "webshell", c.Param("id"))
case strings.HasPrefix(path, "/batch-tasks/:queueId"):
return db.UserCanAccessResource(session.UserID, session.Scope, "batch_task", c.Param("queueId"))
case strings.HasPrefix(path, "/vulnerabilities/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "vulnerability", c.Param("id"))
case strings.HasPrefix(path, "/assets/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "asset", c.Param("id"))
case strings.HasPrefix(path, "/c2/listeners/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "c2_listener", c.Param("id"))
case strings.HasPrefix(path, "/c2/sessions/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "c2_session", c.Param("id"))
case strings.HasPrefix(path, "/c2/tasks/:id"):
return db.UserCanAccessResource(session.UserID, session.Scope, "c2_task", c.Param("id"))
default:
return true
}
}
func isMutationMethod(method string) bool {
switch method {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
return true
default:
return false
}
}
func isProcessGlobalMutationPath(path string) bool {
if strings.HasPrefix(path, "/roles") || strings.HasPrefix(path, "/skills") ||
strings.HasPrefix(path, "/external-mcp") || strings.HasPrefix(path, "/robot") {
return true
}
if strings.HasPrefix(path, "/workflows") {
// Workflow runs inherit conversation access; definitions are global.
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" && path != "/workflows/generate-draft"
}
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
return true
}
if strings.HasPrefix(path, "/knowledge") {
return path != "/knowledge/search"
}
if strings.HasPrefix(path, "/eino-agent/markdown-agents") || strings.HasPrefix(path, "/multi-agent/markdown-agents") {
return true
}
return false
}
+269
View File
@@ -0,0 +1,269 @@
package security
import (
"net/http"
"net/http/httptest"
"testing"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
)
func TestRBACMiddlewareUsesMatchedFullPath(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "operator",
Permissions: map[string]bool{"project:read": true},
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/projects/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/projects/p1", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestRBACMiddlewareRejectsMissingPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "viewer",
Permissions: map[string]bool{"project:read": true},
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.POST("/api/projects", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/projects", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRBACMiddlewareRejectsUnmappedProtectedRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "admin",
Permissions: allPermissions(),
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/new-module", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/new-module", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
}
}
func TestRBACMiddlewareMapsOpenAPISpec(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "u1",
Username: "viewer",
Permissions: map[string]bool{"openapi:read": true},
Scope: database.RBACScopeAll,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/openapi/spec", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/openapi/spec", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestRBACResourcePickerRequiresWritePermission(t *testing.T) {
if got := permissionForRequest(http.MethodGet, "/api/rbac/resources"); got != "rbac:write" {
t.Fatalf("picker permission = %q, want rbac:write", got)
}
if got := permissionForRequest(http.MethodGet, "/api/rbac/resource-assignments"); got != "rbac:read" {
t.Fatalf("assignment list permission = %q, want rbac:read", got)
}
}
func TestMCPInvocationPermissionIsSeparateFromMCPAdministration(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/mcp"); got != "mcp:execute" {
t.Fatalf("MCP invocation permission = %q, want mcp:execute", got)
}
if got := permissionForRequest(http.MethodPut, "/api/external-mcp/example"); got != "mcp:write" {
t.Fatalf("external MCP admin permission = %q, want mcp:write", got)
}
}
func TestConfigToolsReadAllowsMCPReadWithoutConfigRead(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "viewer",
Username: "viewer",
Permissions: map[string]bool{"mcp:read": true},
Scope: database.RBACScopeAssigned,
})
c.Next()
})
router.Use(RBACMiddleware(nil))
router.GET("/api/config/tools", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"tools": []any{}})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/config/tools", nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusOK, w.Body.String())
}
}
func TestWorkflowRunPermissionIsSeparateFromDefinitionManagement(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
t.Fatalf("resume permission = %q, want workflow:execute", got)
}
if got := permissionForRequest(http.MethodPost, "/api/workflows/generate-draft"); got != "workflow:write" {
t.Fatalf("generate draft permission = %q, want workflow:write", got)
}
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
t.Fatalf("definition permission = %q, want workflow:write", got)
}
if isProcessGlobalMutationPath("/workflows/generate-draft") {
t.Fatalf("generate draft should not be treated as a process-global mutation")
}
}
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
gin.SetMode(gin.TestMode)
called := false
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{UserID: "viewer", Permissions: map[string]bool{"project:read": true}, Scope: database.RBACScopeAssigned})
c.Next()
})
router.Use(RBACMiddlewareWithDenyHook(nil, func(_ *gin.Context, reason, permission string) {
called = reason == "permission_denied" && permission == "project:write"
}))
router.POST("/api/projects", func(c *gin.Context) { c.Status(http.StatusNoContent) })
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/projects", nil))
if w.Code != http.StatusForbidden || !called {
t.Fatalf("denial = status %d, hook called %v", w.Code, called)
}
}
func TestRBACMiddlewareBindsPermissionSpecificScope(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "mixed", Scope: database.RBACScopeAll,
Permissions: map[string]bool{"project:read": true, "project:write": true},
PermissionScopes: map[string]string{"project:read": database.RBACScopeAll, "project:write": database.RBACScopeOwn},
})
c.Next()
})
router.Use(RBACMiddleware(nil))
handler := func(c *gin.Context) {
session, _ := CurrentSession(c)
c.String(http.StatusOK, session.Scope)
}
router.GET("/api/projects/:id", handler)
router.PUT("/api/projects/:id", handler)
for _, tc := range []struct{ method, want string }{
{http.MethodGet, database.RBACScopeAll},
{http.MethodPut, database.RBACScopeOwn},
} {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(tc.method, "/api/projects/p1", nil))
if w.Code != http.StatusOK || w.Body.String() != tc.want {
t.Fatalf("%s scope response = %d/%q, want 200/%q", tc.method, w.Code, w.Body.String(), tc.want)
}
}
}
func TestRBACMiddlewareRejectsAssignedScopeForGlobalMonitorAggregates(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
method string
path string
permission string
}{
{method: http.MethodGet, path: "/api/monitor/stats", permission: "monitor:read"},
} {
t.Run(tc.path, func(t *testing.T) {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{
UserID: "assigned-user", Permissions: map[string]bool{tc.permission: true}, Scope: database.RBACScopeAssigned,
})
c.Next()
})
router.Use(RBACMiddleware(&database.DB{}))
router.Handle(tc.method, tc.path, func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", w.Code, http.StatusForbidden)
}
})
}
}
func TestAssignedScopeCannotMutateProcessGlobalAssets(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{"/api/roles/demo", "/api/skills/demo", "/api/external-mcp/demo", "/api/workflows/demo", "/api/knowledge/items/demo"} {
t.Run(path, func(t *testing.T) {
permission := permissionForRequest(http.MethodPut, path)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(ContextSessionKey, Session{UserID: "operator", Scope: database.RBACScopeAssigned, Permissions: map[string]bool{permission: true}, PermissionScopes: map[string]string{permission: database.RBACScopeAssigned}})
c.Next()
})
router.Use(RBACMiddleware(&database.DB{}))
router.PUT(path, func(c *gin.Context) { c.Status(http.StatusNoContent) })
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodPut, path, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("global mutation status = %d, want 403", w.Code)
}
})
}
}
+60
View File
@@ -0,0 +1,60 @@
package security
import (
"go/ast"
"go/parser"
"go/token"
"net/http"
"path/filepath"
"strconv"
"testing"
)
func TestEveryProtectedRouteHasCatalogPermission(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "app", "app.go"), nil, 0)
if err != nil {
t.Fatal(err)
}
methods := map[string]string{
"GET": http.MethodGet, "POST": http.MethodPost, "PUT": http.MethodPut,
"PATCH": http.MethodPatch, "DELETE": http.MethodDelete,
}
prefixes := map[string]string{"protected": "", "c2Routes": "/c2", "knowledgeRoutes": "/knowledge"}
found := 0
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
prefix, protected := prefixes[ident.Name]
method, routeMethod := methods[sel.Sel.Name]
literal, literalPath := call.Args[0].(*ast.BasicLit)
if !protected || !routeMethod || !literalPath || literal.Kind != token.STRING {
return true
}
path, err := strconv.Unquote(literal.Value)
if err != nil {
t.Errorf("invalid route literal %s", literal.Value)
return true
}
found++
permission := permissionForRequest(method, "/api"+prefix+path)
if permission == "" {
t.Errorf("unmapped protected route: %s %s%s", method, prefix, path)
} else if _, ok := PermissionCatalog[permission]; !ok {
t.Errorf("route %s %s%s maps to unknown permission %q", method, prefix, path, permission)
}
return true
})
if found < 100 {
t.Fatalf("route inventory unexpectedly small: %d", found)
}
}
+111
View File
@@ -0,0 +1,111 @@
package security
import "strings"
const backgroundJobStdioRedirect = " </dev/null >/dev/null 2>&1"
// findStandaloneAmpersandPositions 返回不在引号内的独立 & 下标(排除 &&)。
func findStandaloneAmpersandPositions(command string) []int {
command = strings.TrimSpace(command)
if command == "" {
return nil
}
var positions []int
inSingleQuote := false
inDoubleQuote := false
escaped := false
for i := 0; i < len(command); i++ {
r := command[i]
if escaped {
escaped = false
continue
}
if r == '\\' {
escaped = true
continue
}
if r == '\'' && !inDoubleQuote {
inSingleQuote = !inSingleQuote
continue
}
if r == '"' && !inSingleQuote {
inDoubleQuote = !inDoubleQuote
continue
}
if r != '&' || inSingleQuote || inDoubleQuote {
continue
}
if i+1 < len(command) && command[i+1] == '&' {
continue
}
if i > 0 && command[i-1] == '&' {
continue
}
isStandalone := i == 0
if !isStandalone {
prev := command[i-1]
isStandalone = prev == ' ' || prev == '\t' || prev == '\n' || prev == '\r'
}
if !isStandalone {
continue
}
if i == len(command)-1 {
positions = append(positions, i)
continue
}
next := command[i+1]
if next == ' ' || next == '\t' || next == '\n' || next == '\r' {
positions = append(positions, i)
}
}
return positions
}
func segmentHasStdioRedirect(segment string) bool {
lower := strings.ToLower(strings.TrimSpace(segment))
if lower == "" {
return false
}
if strings.Contains(lower, ">/dev/null") || strings.Contains(lower, "2>/dev/null") {
return true
}
if strings.Contains(lower, "&>") || strings.Contains(lower, "&>>") {
return true
}
if strings.Contains(lower, "2>&1") && strings.Contains(lower, "/dev/null") {
return true
}
return false
}
// RedirectBackgroundJobStdio 为每个独立 & 前的后台段注入 </dev/null >/dev/null 2>&1
// 避免后台子进程占用 execute/exec 管道导致挂死。
func RedirectBackgroundJobStdio(command string) string {
positions := findStandaloneAmpersandPositions(command)
if len(positions) == 0 {
return command
}
out := command
for j := len(positions) - 1; j >= 0; j-- {
i := positions[j]
before := out[:i]
after := out[i:]
trimmed := strings.TrimRight(before, " \t\r\n")
if segmentHasStdioRedirect(trimmed) {
continue
}
trailing := before[len(trimmed):]
out = trimmed + backgroundJobStdioRedirect + trailing + after
}
return out
}
// PrepareShellCommandForExecute 组合 execute/exec 用的非交互包装与后台 IO 重定向。
// 须先注入 exec </dev/null,再改写 & 后台段,否则段内 </dev/null 会使 stdin 重定向被误判为已存在。
func PrepareShellCommandForExecute(shellCommand string) string {
return RedirectBackgroundJobStdio(PrepareNonInteractiveShellCommand(shellCommand))
}
@@ -0,0 +1,64 @@
package security
import (
"strings"
"testing"
)
func TestRedirectBackgroundJobStdio_mixedCommand(t *testing.T) {
in := "java -jar app.jar & JRMP_PID=$!; echo started"
out := RedirectBackgroundJobStdio(in)
if !strings.Contains(out, "java -jar app.jar </dev/null >/dev/null 2>&1 &") {
t.Fatalf("expected redirect before &: %q", out)
}
if !strings.Contains(out, "echo started") {
t.Fatalf("foreground tail preserved: %q", out)
}
}
func TestRedirectBackgroundJobStdio_trailingOnly(t *testing.T) {
in := "sleep 120 &"
out := RedirectBackgroundJobStdio(in)
want := "sleep 120 </dev/null >/dev/null 2>&1 &"
if strings.TrimSpace(out) != want {
t.Fatalf("got %q want %q", out, want)
}
}
func TestRedirectBackgroundJobStdio_skipsAlreadyRedirected(t *testing.T) {
in := "sleep 1 >/dev/null 2>&1 & echo ok"
out := RedirectBackgroundJobStdio(in)
if out != in {
t.Fatalf("should not double-redirect: %q", out)
}
}
func TestRedirectBackgroundJobStdio_skipsAndAnd(t *testing.T) {
in := "test -f /etc/passwd && echo ok"
out := RedirectBackgroundJobStdio(in)
if out != in {
t.Fatalf("&& must not be treated as background &: %q", out)
}
}
func TestPrepareShellCommandForExecute(t *testing.T) {
out := PrepareShellCommandForExecute("java -jar x & echo hi")
if !strings.Contains(out, "exec </dev/null") {
t.Fatalf("missing stdin redirect: %q", out)
}
if !strings.Contains(out, "GIT_PAGER=cat") {
t.Fatalf("missing pager export: %q", out)
}
if !strings.Contains(out, "java -jar x </dev/null >/dev/null 2>&1 &") {
t.Fatalf("missing background redirect: %q", out)
}
}
func TestIsBackgroundShellCommand_usesSharedParser(t *testing.T) {
if !IsBackgroundShellCommand("sleep 1 &") {
t.Fatal("trailing & should be background")
}
if IsBackgroundShellCommand("sleep 1 & echo hi") {
t.Fatal("mixed should not be fully background")
}
}
+211
View File
@@ -0,0 +1,211 @@
package security
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"sync"
"github.com/cloudwego/eino/adk/filesystem"
"github.com/cloudwego/eino/schema"
)
// ConfigureShellCmdForAgentExecute 与 exec 工具一致:非交互 stdin、pager/TERM 环境、独立进程组。
func ConfigureShellCmdForAgentExecute(cmd *exec.Cmd) {
if cmd == nil {
return
}
applyDefaultTerminalEnv(cmd)
attachNonInteractiveStdin(cmd)
_ = prepareShellCmdSession(cmd)
}
// TerminateShellCmdTree 尽力终止 shell 及其子进程组(与 exec/execute 超时取消一致)。
func TerminateShellCmdTree(cmd *exec.Cmd) {
terminateCmdTree(cmd)
}
// TerminateShellCmdSession 使用 Start 时缓存的进程组 ID 终止(shell 已退出时仍有效)。
func TerminateShellCmdSession(session *ShellSession) {
TerminateShellSession(session)
}
// EinoStreamingShell 为 Eino ADK execute 工具提供流式 shell,行为与 exec 对齐:
// 并发读取 stdout/stderr(定长块,非按行),避免官方 local.ExecuteStreaming 先排空 stdout
// 导致 stderr 错误(如 sudo 密码提示)长时间不可见、UI 一直显示「执行中」。
type EinoStreamingShell struct{}
// NewEinoStreamingShell 创建 execute 流式 shell 实现。
func NewEinoStreamingShell() *EinoStreamingShell {
return &EinoStreamingShell{}
}
// ExecuteStreaming 实现 filesystem.StreamingShell。
func (s *EinoStreamingShell) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
if input == nil || input.Command == "" {
return nil, fmt.Errorf("command is required")
}
sr, w := schema.Pipe[*filesystem.ExecuteResponse](100)
if input.RunInBackendGround {
go runShellInBackground(ctx, input.Command, w)
return sr, nil
}
go streamShellForeground(ctx, input.Command, w)
return sr, nil
}
func runShellInBackground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) {
defer w.Close()
command = PrepareShellCommandForExecute(command)
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
applyDefaultTerminalEnv(cmd)
attachNonInteractiveStdin(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
_ = w.Send(nil, fmt.Errorf("failed to create stdout pipe: %w", err))
return
}
stderr, err := cmd.StderrPipe()
if err != nil {
_ = stdout.Close()
_ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err))
return
}
session, err := StartShellSession(cmd)
if err != nil {
_ = stdout.Close()
_ = stderr.Close()
_ = w.Send(nil, fmt.Errorf("failed to start command: %w", err))
return
}
done := make(chan struct{})
go func() {
drainShellPipes(stdout, stderr)
_ = session.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
TerminateShellCmdSession(session)
}
exitCode := 0
_ = w.Send(&filesystem.ExecuteResponse{
Output: "command started in background\n",
ExitCode: &exitCode,
}, nil)
}
func drainShellPipes(stdout, stderr io.Reader) {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(io.Discard, stdout)
}()
go func() {
defer wg.Done()
_, _ = io.Copy(io.Discard, stderr)
}()
wg.Wait()
}
func streamShellForeground(ctx context.Context, command string, w *schema.StreamWriter[*filesystem.ExecuteResponse]) {
defer w.Close()
command = PrepareShellCommandForExecute(command)
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
applyDefaultTerminalEnv(cmd)
attachNonInteractiveStdin(cmd)
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
_ = w.Send(nil, fmt.Errorf("failed to create stdout pipe: %w", err))
return
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
_ = stdoutPipe.Close()
_ = w.Send(nil, fmt.Errorf("failed to create stderr pipe: %w", err))
return
}
session, err := StartShellSession(cmd)
if err != nil {
_ = stdoutPipe.Close()
_ = stderrPipe.Close()
_ = w.Send(nil, fmt.Errorf("failed to start command: %w", err))
return
}
stopWatch := make(chan struct{})
go func() {
select {
case <-ctx.Done():
TerminateShellCmdSession(session)
case <-stopWatch:
}
}()
defer close(stopWatch)
chunks := make(chan string, 64)
var wg sync.WaitGroup
readFn := func(r io.Reader) {
defer wg.Done()
buf := make([]byte, 8192)
for {
n, readErr := r.Read(buf)
if n > 0 {
chunks <- string(buf[:n])
}
if readErr != nil {
return
}
}
}
wg.Add(2)
go readFn(stdoutPipe)
go readFn(stderrPipe)
go func() {
wg.Wait()
close(chunks)
}()
hadOutput := false
for chunk := range chunks {
if chunk == "" {
continue
}
hadOutput = true
if w.Send(&filesystem.ExecuteResponse{Output: chunk}, nil) {
TerminateShellCmdSession(session)
return
}
}
waitErr := session.Wait()
if waitErr == nil {
exitCode := 0
_ = w.Send(&filesystem.ExecuteResponse{ExitCode: &exitCode}, nil)
return
}
var exitError *exec.ExitError
if errors.As(waitErr, &exitError) {
exitCode := exitError.ExitCode()
resp := &filesystem.ExecuteResponse{ExitCode: &exitCode}
if !hadOutput {
resp.Output = FormatCommandFailureResult(exitCode, "")
}
_ = w.Send(resp, nil)
return
}
_ = w.Send(nil, fmt.Errorf("command failed: %w", waitErr))
}
@@ -0,0 +1,152 @@
package security
import (
"context"
"errors"
"io"
"strings"
"testing"
"time"
"github.com/cloudwego/eino/adk/filesystem"
)
func TestEinoStreamingShell_StreamsStderrBeforeStdoutEOF(t *testing.T) {
shell := NewEinoStreamingShell()
cmd := PrepareNonInteractiveShellCommand("echo err-only >&2; exit 1")
sr, err := shell.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: cmd})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
start := time.Now()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("recv: %v", rerr)
}
if resp != nil && resp.Output != "" {
got.WriteString(resp.Output)
}
}
if time.Since(start) > 3*time.Second {
t.Fatalf("expected fast completion, took %v", time.Since(start))
}
if !strings.Contains(got.String(), "err-only") {
t.Fatalf("expected stderr in output, got: %q", got.String())
}
}
func TestEinoStreamingShell_SudoFailsFast(t *testing.T) {
shell := NewEinoStreamingShell()
cmd := PrepareNonInteractiveShellCommand("sudo whoami && sudo cat /etc/os-release")
sr, err := shell.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: cmd})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
start := time.Now()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("recv: %v", rerr)
}
if resp == nil {
continue
}
got.WriteString(resp.Output)
}
if time.Since(start) > 5*time.Second {
t.Fatalf("sudo should fail quickly, took %v output=%q", time.Since(start), got.String())
}
out := got.String()
if strings.Contains(out, "command exited with non-zero code") {
t.Fatalf("legacy exit line present: %q", out)
}
if !strings.Contains(out, "sudo") && !strings.Contains(out, "password") && !strings.Contains(out, "terminal") {
t.Fatalf("expected sudo error text, got: %q", out)
}
}
func TestEinoStreamingShell_StderrWhileStdoutBlocks(t *testing.T) {
shell := NewEinoStreamingShell()
// 模拟 sudostderr 先有输出,stdout 侧进程仍挂起;旧 eino local 在首包 stderr 前不会向流写任何内容。
cmd := PrepareNonInteractiveShellCommand(`echo "password prompt" >&2; sleep 30`)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
sr, err := shell.ExecuteStreaming(ctx, &filesystem.ExecuteRequest{Command: cmd})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
start := time.Now()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
break
}
if resp != nil && resp.Output != "" {
got.WriteString(resp.Output)
if strings.Contains(got.String(), "password prompt") {
break
}
}
}
if time.Since(start) > 1500*time.Millisecond {
t.Fatalf("expected stderr promptly, took %v output=%q", time.Since(start), got.String())
}
if !strings.Contains(got.String(), "password prompt") {
t.Fatalf("expected early stderr, got: %q", got.String())
}
}
// TestEinoStreamingShell_BackgroundJobDoesNotHoldPipe 模拟 cmd & 后继续前台逻辑:重定向后应快速结束。
func TestEinoStreamingShell_BackgroundJobDoesNotHoldPipe(t *testing.T) {
if testing.Short() {
t.Skip("skipping shell integration in -short")
}
shell := NewEinoStreamingShell()
cmd := `(sh -c 'printf x; sleep 120') & echo started; sleep 0`
sr, err := shell.ExecuteStreaming(context.Background(), &filesystem.ExecuteRequest{Command: cmd})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
start := time.Now()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("recv: %v", rerr)
}
if resp != nil && resp.Output != "" {
got.WriteString(resp.Output)
}
}
if time.Since(start) > 3*time.Second {
t.Fatalf("expected fast completion, took %v output=%q", time.Since(start), got.String())
}
if !strings.Contains(got.String(), "started") {
t.Fatalf("expected foreground echo, got: %q", got.String())
}
}
+163
View File
@@ -0,0 +1,163 @@
package security
import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// ShellNoOutputTimeoutMessage 长时间无新 stdout/stderr 时的提示(软失败,模型可见)。
func ShellNoOutputTimeoutMessage(idleSec int) string {
return fmt.Sprintf(`命令已终止:超过 %d 秒没有新的输出,疑似在等待交互输入或已挂起。
长时静默任务请使用末尾 & 后台运行,或增大 agent.shell_no_output_timeout_seconds-1=关闭此检测)。
Command terminated: no new output for %d seconds (possible interactive wait or hung process).`, idleSec, idleSec)
}
// ShellInactivityWatch 在 noOutputSec 内无任何新输出时向 expired 发送信号;每次 Bump 重置计时。
// 与「仅有首包输出就永久取消计时」不同,可兜住 sudo 打印 Password 提示后继续挂起等情况。
type ShellInactivityWatch struct {
Sec int
mu sync.Mutex
timer *time.Timer
Expired chan struct{}
}
func NewShellInactivityWatch(noOutputSec int) *ShellInactivityWatch {
sec := ResolveShellNoOutputTimeoutSeconds(noOutputSec)
if sec <= 0 {
return nil
}
w := &ShellInactivityWatch{
Sec: sec,
Expired: make(chan struct{}, 1),
}
w.Bump()
return w
}
func (w *ShellInactivityWatch) Bump() {
if w == nil || w.Sec <= 0 {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if w.timer != nil {
w.timer.Stop()
}
w.timer = time.AfterFunc(time.Duration(w.Sec)*time.Second, func() {
select {
case w.Expired <- struct{}{}:
default:
}
})
}
func (w *ShellInactivityWatch) Stop() {
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if w.timer != nil {
w.timer.Stop()
w.timer = nil
}
}
// ResolveShellNoOutputTimeoutSeconds0=默认 3005 分钟);-1=关闭;>0=自定义。
func ResolveShellNoOutputTimeoutSeconds(sec int) int {
if sec < 0 {
return 0
}
if sec == 0 {
return 300
}
return sec
}
// PrependNonInteractiveShellExports 为 sh -c 注入通用非交互环境(pager 等),不维护命令黑名单。
func PrependNonInteractiveShellExports(shellCommand string) string {
if strings.TrimSpace(shellCommand) == "" {
return shellCommand
}
upper := strings.ToUpper(shellCommand)
var pairs []string
add := func(key, val string) {
if strings.Contains(upper, strings.ToUpper(key)) {
return
}
pairs = append(pairs, key+"="+val)
}
add("GIT_PAGER", "cat")
add("PAGER", "cat")
add("SYSTEMD_PAGER", "cat")
add("DEBIAN_FRONTEND", "noninteractive")
if len(pairs) == 0 {
return shellCommand
}
return "export " + strings.Join(pairs, " ") + "\n" + shellCommand
}
// PrependNonInteractiveStdinRedirect 为 sh -c 关闭 stdin(与 attachNonInteractiveStdin 等价),
// 使 read/input()/sudo -S 等从 stdin 读取的程序快速失败而非挂起。已含 </dev/null 时不重复注入。
func PrependNonInteractiveStdinRedirect(shellCommand string) string {
if strings.TrimSpace(shellCommand) == "" {
return shellCommand
}
lower := strings.ToLower(shellCommand)
if strings.Contains(lower, "</dev/null") || strings.Contains(lower, "0</dev/null") {
return shellCommand
}
return "exec </dev/null\n" + shellCommand
}
// PrepareNonInteractiveShellCommand 组合非交互包装:stdin 关闭 + pager 等环境变量(零名单)。
func PrepareNonInteractiveShellCommand(shellCommand string) string {
return PrependNonInteractiveStdinRedirect(PrependNonInteractiveShellExports(shellCommand))
}
// ApplyNonInteractivePagerEnv 为 exec.Cmd 补齐与 PrependNonInteractiveShellExports 一致的环境变量。
func ApplyNonInteractivePagerEnv(cmdEnv []string) []string {
if cmdEnv == nil {
cmdEnv = []string{}
}
has := func(k string) bool {
prefix := k + "="
for _, e := range cmdEnv {
if strings.HasPrefix(e, prefix) {
return true
}
}
return false
}
if !has("GIT_PAGER") {
cmdEnv = append(cmdEnv, "GIT_PAGER=cat")
}
if !has("PAGER") {
cmdEnv = append(cmdEnv, "PAGER=cat")
}
if !has("SYSTEMD_PAGER") {
cmdEnv = append(cmdEnv, "SYSTEMD_PAGER=cat")
}
if !has("DEBIAN_FRONTEND") {
cmdEnv = append(cmdEnv, "DEBIAN_FRONTEND=noninteractive")
}
return cmdEnv
}
// attachNonInteractiveStdin 关闭交互式 stdin,使部分命令快速失败而非等待输入。
func attachNonInteractiveStdin(cmd *exec.Cmd) {
if cmd == nil || cmd.Stdin != nil {
return
}
f, err := os.Open(os.DevNull)
if err != nil {
return
}
cmd.Stdin = f
}
@@ -0,0 +1,128 @@
package security
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestPrependNonInteractiveShellExports(t *testing.T) {
out := PrependNonInteractiveShellExports("echo hi")
if !strings.Contains(out, "GIT_PAGER=cat") || !strings.Contains(out, "PAGER=cat") {
t.Fatalf("missing pager exports: %q", out)
}
if !strings.HasSuffix(strings.TrimSpace(out), "echo hi") {
t.Fatalf("command suffix lost: %q", out)
}
skip := PrependNonInteractiveShellExports("GIT_PAGER=less echo hi")
if strings.Contains(skip, "export GIT_PAGER=cat") {
t.Fatalf("should not override existing GIT_PAGER: %q", skip)
}
}
func TestPrependNonInteractiveStdinRedirect(t *testing.T) {
out := PrependNonInteractiveStdinRedirect("echo hi")
if !strings.HasPrefix(out, "exec </dev/null") {
t.Fatalf("missing stdin redirect: %q", out)
}
if !strings.HasSuffix(strings.TrimSpace(out), "echo hi") {
t.Fatalf("command suffix lost: %q", out)
}
skip := PrependNonInteractiveStdinRedirect("cmd </dev/null")
if strings.HasPrefix(skip, "exec </dev/null") {
t.Fatalf("should not double redirect: %q", skip)
}
}
func TestPrepareNonInteractiveShellCommand(t *testing.T) {
out := PrepareNonInteractiveShellCommand("echo hi")
if !strings.Contains(out, "exec </dev/null") {
t.Fatalf("missing stdin redirect: %q", out)
}
if !strings.Contains(out, "GIT_PAGER=cat") {
t.Fatalf("missing pager export: %q", out)
}
}
func TestNewShellInactivityWatch(t *testing.T) {
w := NewShellInactivityWatch(1)
if w == nil {
t.Fatal("expected watch")
}
w.Bump()
select {
case <-w.Expired:
case <-time.After(3 * time.Second):
t.Fatal("expected inactivity fire within 3s")
}
}
func TestResolveShellNoOutputTimeoutSeconds(t *testing.T) {
if ResolveShellNoOutputTimeoutSeconds(0) != 300 {
t.Fatal("zero should default to 300")
}
if ResolveShellNoOutputTimeoutSeconds(-1) != 0 {
t.Fatal("-1 should disable")
}
if ResolveShellNoOutputTimeoutSeconds(30) != 30 {
t.Fatal("explicit value")
}
}
// TestNonInteractiveStdinReadExitsQuickly 验证 exec </dev/null + attachNonInteractiveStdin 时 read 立即 EOF,不挂起。
func TestNonInteractiveStdinReadExitsQuickly(t *testing.T) {
if testing.Short() {
t.Skip("skipping shell integration in -short")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", PrepareNonInteractiveShellCommand(`read x; echo "x=<$x>"`))
attachNonInteractiveStdin(cmd)
start := time.Now()
out, err := cmd.CombinedOutput()
elapsed := time.Since(start)
if elapsed > 2*time.Second {
t.Fatalf("read with closed stdin took %v, want <2s", elapsed)
}
if err != nil {
t.Fatalf("unexpected error: %v output=%q", err, out)
}
if !strings.Contains(string(out), "x=<>") {
t.Fatalf("unexpected output: %q", out)
}
}
// TestNonInteractiveStdinReadBlocksWithoutRedirect 对照:stdin 为永不写入的管道时 read 会挂起。
func TestNonInteractiveStdinReadBlocksWithoutRedirect(t *testing.T) {
if testing.Short() {
t.Skip("skipping shell integration in -short")
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
// 保持 w 打开且不写数据,模拟「等待用户输入」
cmd := exec.Command("sh", "-c", `read x; echo done`)
cmd.Stdin = r
done := make(chan error, 1)
go func() { done <- cmd.Run() }()
select {
case err := <-done:
t.Fatalf("expected hang, but command finished: %v", err)
case <-time.After(500 * time.Millisecond):
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = w.Close()
<-done // 等待 goroutine 退出
}
}
+47
View File
@@ -0,0 +1,47 @@
package security
import "os/exec"
// ShellSession 在 Start 时记录根 shell 的进程组 ID,取消/超时时可杀整组(即使 cmd.Process 已失效)。
type ShellSession struct {
Cmd *exec.Cmd
rootPID int
}
// StartShellSession 配置独立进程组并启动 shell,缓存 rootPIDUnix 下即 PGID)。
func StartShellSession(cmd *exec.Cmd) (*ShellSession, error) {
if err := prepareShellCmdSession(cmd); err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
pid := 0
if cmd.Process != nil {
pid = cmd.Process.Pid
}
return &ShellSession{Cmd: cmd, rootPID: pid}, nil
}
// Wait 等待 shell 退出。
func (s *ShellSession) Wait() error {
if s == nil || s.Cmd == nil {
return nil
}
return s.Cmd.Wait()
}
// Terminate 终止 shell 及其进程组。
func (s *ShellSession) Terminate() {
if s == nil {
return
}
terminateProcessGroup(s.rootPID, s.Cmd)
}
// TerminateShellSession 终止由 StartShellSession 启动的会话。
func TerminateShellSession(session *ShellSession) {
if session != nil {
session.Terminate()
}
}
+65
View File
@@ -0,0 +1,65 @@
package security
import (
"context"
"os/exec"
"runtime"
"testing"
"time"
)
func TestShellSession_TerminateUsesCachedRootPID(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("unix process group kill")
}
cmd := exec.Command("sh", "-c", "sleep 300")
ConfigureShellCmdForAgentExecute(cmd)
session, err := StartShellSession(cmd)
if err != nil {
t.Fatalf("StartShellSession: %v", err)
}
time.Sleep(100 * time.Millisecond)
session.Terminate()
done := make(chan error, 1)
go func() { done <- session.Wait() }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("session did not finish within 5s after Terminate")
}
}
func TestShellSession_TerminateAfterContextCancel(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)
session, err := StartShellSession(cmd)
if err != nil {
t.Fatalf("StartShellSession: %v", err)
}
time.Sleep(100 * time.Millisecond)
cancel()
TerminateShellCmdSession(session)
done := make(chan error, 1)
go func() { done <- session.Wait() }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("session did not finish within 5s after cancel+terminate")
}
}
@@ -0,0 +1,20 @@
package security
import (
"net/http"
"testing"
)
func TestWorkflowPackageRoutesHaveExplicitWorkflowPermissions(t *testing.T) {
if got := permissionForRequest(http.MethodGet, "/api/workflows/:id/package"); got != "workflow:read" {
t.Fatalf("export permission=%q", got)
}
for _, path := range []string{"/api/workflow-package-inspections", "/api/workflow-package-inspections/:inspectionId", "/api/workflow-package-imports", "/api/workflow-package-imports/:importId"} {
if got := permissionForRequest(http.MethodGet, path); got != "workflow:write" {
t.Fatalf("%s permission=%q", path, got)
}
}
if !isProcessGlobalMutationPath("/workflow-package-imports") || !isProcessGlobalMutationPath("/workflow-package-inspections") {
t.Fatal("package mutations must require all-resource scope")
}
}