mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-15 15:40:38 +02:00
Delete internal directory
This commit is contained in:
@@ -1,266 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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 ""
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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 ")
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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
@@ -1,282 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//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 对应进程组发 SIGKILL;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
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
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()
|
||||
// 模拟 sudo:stderr 先有输出,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())
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveShellNoOutputTimeoutSeconds:0=默认 300(5 分钟);-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
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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 退出
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package security
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// ShellSession 在 Start 时记录根 shell 的进程组 ID,取消/超时时可杀整组(即使 cmd.Process 已失效)。
|
||||
type ShellSession struct {
|
||||
Cmd *exec.Cmd
|
||||
rootPID int
|
||||
}
|
||||
|
||||
// StartShellSession 配置独立进程组并启动 shell,缓存 rootPID(Unix 下即 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()
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user