Add files via upload

This commit is contained in:
公明
2026-07-10 18:58:17 +08:00
committed by GitHub
parent 2995847e0e
commit 46a9b42fde
8 changed files with 351 additions and 29 deletions
+4 -4
View File
@@ -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,
+25 -16
View File
@@ -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) == "" {
+44
View File
@@ -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)
}
}
+14 -4
View File
@@ -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 {
+6 -2
View File
@@ -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",
+80 -3
View File
@@ -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
View File
@@ -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)
}
})
}
}
+60
View File
@@ -0,0 +1,60 @@
package security
import (
"go/ast"
"go/parser"
"go/token"
"net/http"
"path/filepath"
"strconv"
"testing"
)
func TestEveryProtectedRouteHasCatalogPermission(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), filepath.Join("..", "app", "app.go"), nil, 0)
if err != nil {
t.Fatal(err)
}
methods := map[string]string{
"GET": http.MethodGet, "POST": http.MethodPost, "PUT": http.MethodPut,
"PATCH": http.MethodPatch, "DELETE": http.MethodDelete,
}
prefixes := map[string]string{"protected": "", "c2Routes": "/c2", "knowledgeRoutes": "/knowledge"}
found := 0
ast.Inspect(file, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok || len(call.Args) == 0 {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
prefix, protected := prefixes[ident.Name]
method, routeMethod := methods[sel.Sel.Name]
literal, literalPath := call.Args[0].(*ast.BasicLit)
if !protected || !routeMethod || !literalPath || literal.Kind != token.STRING {
return true
}
path, err := strconv.Unquote(literal.Value)
if err != nil {
t.Errorf("invalid route literal %s", literal.Value)
return true
}
found++
permission := permissionForRequest(method, "/api"+prefix+path)
if permission == "" {
t.Errorf("unmapped protected route: %s %s%s", method, prefix, path)
} else if _, ok := PermissionCatalog[permission]; !ok {
t.Errorf("route %s %s%s maps to unknown permission %q", method, prefix, path, permission)
}
return true
})
if found < 100 {
t.Fatalf("route inventory unexpectedly small: %d", found)
}
}