mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-14 07:57:26 +02:00
Add files via upload
This commit is contained in:
@@ -5,9 +5,9 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
@@ -173,15 +173,16 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
|
||||
}
|
||||
|
||||
// 交叉编译
|
||||
payloadID := "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
|
||||
binName := strings.TrimSpace(in.OutputName)
|
||||
if binName == "" {
|
||||
binName = fmt.Sprintf("beacon_%s_%s", goos, goarch)
|
||||
binName = fmt.Sprintf("beacon_%s_%s_%s", goos, goarch, payloadID)
|
||||
}
|
||||
if goos == "windows" && !strings.HasSuffix(binName, ".exe") {
|
||||
binName += ".exe"
|
||||
}
|
||||
binPath := filepath.Join(b.outputDir, binName)
|
||||
|
||||
|
||||
if err := os.MkdirAll(b.outputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir output: %w", err)
|
||||
}
|
||||
@@ -214,7 +215,6 @@ func (b *PayloadBuilder) BuildBeacon(in PayloadBuilderInput) (*BuildResult, erro
|
||||
return nil, fmt.Errorf("stat output: %w", err)
|
||||
}
|
||||
|
||||
payloadID := "p_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:14]
|
||||
return &BuildResult{
|
||||
PayloadID: payloadID,
|
||||
ListenerID: listener.ID,
|
||||
|
||||
@@ -19,14 +19,15 @@ var (
|
||||
|
||||
// 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
|
||||
Scope string
|
||||
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.
|
||||
@@ -135,17 +136,25 @@ func (a *AuthManager) authenticateSession(username, password string) (Session, e
|
||||
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,
|
||||
Scope: access.Scope,
|
||||
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) == "" {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -50,4 +54,44 @@ func TestAuthManagerAuthenticatesCreatedRBACUser(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -36,6 +37,11 @@ func AuthMiddleware(manager *AuthManager) gin.HandlerFunc {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -79,12 +85,12 @@ func RequireResourcePermission(db *database.DB, permission, resourceType, paramN
|
||||
return
|
||||
}
|
||||
if db == nil {
|
||||
c.Next()
|
||||
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"error": "资源鉴权服务不可用"})
|
||||
return
|
||||
}
|
||||
resourceID := strings.TrimSpace(c.Param(paramName))
|
||||
if resourceID == "" {
|
||||
c.Next()
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "资源 ID 不能为空"})
|
||||
return
|
||||
}
|
||||
session, ok := CurrentSession(c)
|
||||
@@ -129,8 +135,12 @@ func extractTokenFromRequest(c *gin.Context) string {
|
||||
return strings.TrimSpace(authHeader)
|
||||
}
|
||||
|
||||
if token := c.Query("token"); token != "" {
|
||||
return strings.TrimSpace(token)
|
||||
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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ var PermissionCatalog = map[string]string{
|
||||
"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",
|
||||
@@ -37,7 +38,9 @@ var PermissionCatalog = map[string]string{
|
||||
"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:write": "Manage external MCP servers and invoke MCP endpoint",
|
||||
"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",
|
||||
@@ -51,7 +54,8 @@ var PermissionCatalog = map[string]string{
|
||||
"roles:write": "Create and update AI testing roles",
|
||||
"roles:delete": "Delete AI testing roles",
|
||||
"workflow:read": "View workflow definitions and runs",
|
||||
"workflow:write": "Create, update, validate, run, and resume workflows",
|
||||
"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",
|
||||
|
||||
@@ -12,22 +12,44 @@ import (
|
||||
// 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
|
||||
}
|
||||
if SessionHasPermission(c, permission) {
|
||||
// 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()
|
||||
return
|
||||
}
|
||||
if denyHook != nil {
|
||||
denyHook(c, "permission_denied", permission)
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "权限不足",
|
||||
"permission": permission,
|
||||
@@ -59,7 +81,10 @@ func permissionForRequest(method, fullPath string) string {
|
||||
}
|
||||
return "agent:execute"
|
||||
case strings.HasPrefix(path, "/hitl"):
|
||||
return crudPermission(method, "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"):
|
||||
@@ -79,11 +104,19 @@ func permissionForRequest(method, fullPath string) string {
|
||||
return "terminal:execute"
|
||||
case strings.HasPrefix(path, "/audit"):
|
||||
return crudPermission(method, "audit")
|
||||
case strings.HasPrefix(path, "/external-mcp"), path == "/mcp":
|
||||
return crudPermission(method, "mcp")
|
||||
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")
|
||||
@@ -98,6 +131,9 @@ func permissionForRequest(method, fullPath string) string {
|
||||
case strings.HasPrefix(path, "/roles"):
|
||||
return crudPermission(method, "roles")
|
||||
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")
|
||||
@@ -128,6 +164,20 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
|
||||
}
|
||||
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"):
|
||||
@@ -154,3 +204,30 @@ func resourceAllowed(c *gin.Context, db *database.DB) bool {
|
||||
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"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -118,3 +118,121 @@ func TestRBACResourcePickerRequiresWritePermission(t *testing.T) {
|
||||
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 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.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
|
||||
t.Fatalf("definition permission = %q, want workflow:write", got)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEveryProtectedRouteHasCatalogPermission(t *testing.T) {
|
||||
file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "app", "app.go"), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
methods := map[string]string{
|
||||
"GET": http.MethodGet, "POST": http.MethodPost, "PUT": http.MethodPut,
|
||||
"PATCH": http.MethodPatch, "DELETE": http.MethodDelete,
|
||||
}
|
||||
prefixes := map[string]string{"protected": "", "c2Routes": "/c2", "knowledgeRoutes": "/knowledge"}
|
||||
found := 0
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok || len(call.Args) == 0 {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
ident, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
prefix, protected := prefixes[ident.Name]
|
||||
method, routeMethod := methods[sel.Sel.Name]
|
||||
literal, literalPath := call.Args[0].(*ast.BasicLit)
|
||||
if !protected || !routeMethod || !literalPath || literal.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
path, err := strconv.Unquote(literal.Value)
|
||||
if err != nil {
|
||||
t.Errorf("invalid route literal %s", literal.Value)
|
||||
return true
|
||||
}
|
||||
found++
|
||||
permission := permissionForRequest(method, "/api"+prefix+path)
|
||||
if permission == "" {
|
||||
t.Errorf("unmapped protected route: %s %s%s", method, prefix, path)
|
||||
} else if _, ok := PermissionCatalog[permission]; !ok {
|
||||
t.Errorf("route %s %s%s maps to unknown permission %q", method, prefix, path, permission)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found < 100 {
|
||||
t.Fatalf("route inventory unexpectedly small: %d", found)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user