diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index 80da05dcfa84..21b48164113f 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -78,15 +78,13 @@ var ( ErrParameterRelatedEntityType = errors.New("invalid related entity type") ) -func graphRelatedEntityType(request *http.Request, db database.Database, graphDb graph.Database, entityType, objectID string) (any, int, *api.ErrorWrapper) { - ctx := request.Context() - - primaryDisplayKinds, err := db.GetPrimaryDisplayKinds(request.Context()) - if err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching primary display kinds: %v", err), request) - } +func graphRelatedEntityType(request *http.Request, graphDb graph.Database, primaryDisplayKinds graphschema.PrimaryDisplayKinds, options relatedEntityTypeOptions) (any, int, *api.ErrorWrapper) { + var ( + ctx = request.Context() + objectID = options.sourceObjectID + ) - switch relatedEntityType := azure.RelatedEntityType(entityType); relatedEntityType { + switch relatedEntityType := azure.RelatedEntityType(options.relatedEntityString); relatedEntityType { case azure.RelatedEntityTypeDescendentUsers, azure.RelatedEntityTypeDescendentGroups, azure.RelatedEntityTypeDescendentManagementGroups, azure.RelatedEntityTypeDescendentSubscriptions, azure.RelatedEntityTypeDescendentResourceGroups, azure.RelatedEntityTypeDescendentVirtualMachines, @@ -98,116 +96,116 @@ func graphRelatedEntityType(request *http.Request, db database.Database, graphDb azure.RelatedEntityTypeDescendentWebApps, azure.RelatedEntityTypeDescendentAutomationAccounts, azure.RelatedEntityTypeDescendentLogicApps, azure.RelatedEntityTypeDescendentFunctionApps: - if descendents, err := azure.ListEntityDescendentPaths(ctx, graphDb, relatedEntityType, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + if descendents, err := azure.ListEntityDescendentPaths(ctx, graphDb, relatedEntityType, options.sourceKind, objectID); err != nil { + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, descendents), descendents.Len(), nil } case azure.RelatedEntityTypeActiveAssignments: if assignments, err := azure.ListEntityActiveAssignmentPaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, assignments), assignments.Len(), nil } case azure.RelatedEntityTypePIMAssignments: if assignments, err := azure.ListEntityPIMAssignmentPaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, assignments), assignments.Len(), nil } case azure.RelatedEntityTypeRoleApprovers: if approvers, err := azure.ListRoleApproverPaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, approvers), approvers.Len(), nil } case azure.RelatedEntityTypeVaultKeyReaders, azure.RelatedEntityTypeVaultSecretReaders, azure.RelatedEntityTypeVaultCertReaders, azure.RelatedEntityTypeVaultAllReaders: if groupMembers, err := azure.ListKeyVaultReaderPaths(ctx, graphDb, relatedEntityType, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, groupMembers), groupMembers.Len(), nil } case azure.RelatedEntityTypeGroupMembers: if groupMembers, err := azure.ListEntityGroupMemberPaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, groupMembers), groupMembers.Len(), nil } case azure.RelatedEntityTypeGroupMembership: if groupMembership, err := azure.ListEntityGroupMembershipPaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, groupMembership), groupMembership.Len(), nil } case azure.RelatedEntityTypeRoles: if userRoles, err := azure.ListEntityRolePaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, userRoles), userRoles.Len(), nil } case azure.RelatedEntityTypeEligibleAndApproverRoles: if eligibleAndApproverRoles, err := azure.ListEntityEligibleAndApproverRolePaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, eligibleAndApproverRoles), eligibleAndApproverRoles.Len(), nil } case azure.RelatedEntityTypeOutboundExecutionPrivileges: if executionPrivileges, err := azure.ListEntityExecutionPrivilegePaths(ctx, graphDb, objectID, graph.DirectionOutbound); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, executionPrivileges), executionPrivileges.Len(), nil } case azure.RelatedEntityTypeInboundExecutionPrivileges: if executionPrivileges, err := azure.ListEntityExecutionPrivilegePaths(ctx, graphDb, objectID, graph.DirectionInbound); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, executionPrivileges), executionPrivileges.Len(), nil } case azure.RelatedEntityTypeOutboundAbusableAppRoleAssignments: if objectControl, err := azure.ListEntityAbusableAppRoleAssignmentsPaths(ctx, graphDb, objectID, graph.DirectionOutbound); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, objectControl), objectControl.Len(), nil } case azure.RelatedEntityTypeInboundAbusableAppRoleAssignments: if objectControl, err := azure.ListEntityAbusableAppRoleAssignmentsPaths(ctx, graphDb, objectID, graph.DirectionInbound); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, objectControl), objectControl.Len(), nil } case azure.RelatedEntityTypeOutboundControl: if objectControl, err := azure.ListEntityObjectControlPaths(ctx, graphDb, objectID, graph.DirectionOutbound); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, objectControl), objectControl.Len(), nil } case azure.RelatedEntityTypeInboundControl: if objectControl, err := azure.ListEntityObjectControlPaths(ctx, graphDb, objectID, graph.DirectionInbound); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, objectControl), objectControl.Len(), nil } case azure.RelatedEntityTypeFederatedIdentityCredentials: if fics, err := azure.ListAppFederatedIdentityCredentialPaths(ctx, graphDb, objectID); err != nil { - return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", entityType, err), request) + return nil, 0, api.BuildErrorResponse(http.StatusInternalServerError, fmt.Sprintf("error fetching related entity type %s: %v", options.relatedEntityString, err), request) } else { return bloodhoundgraph.PathSetToBloodHoundGraph(primaryDisplayKinds, fics), fics.Len(), nil } default: - return nil, 0, api.BuildErrorResponse(http.StatusNotFound, fmt.Sprintf("no matching related entity list type for %s", entityType), request) + return nil, 0, api.BuildErrorResponse(http.StatusNotFound, fmt.Sprintf("no matching related entity list type for %s", options.relatedEntityString), request) } } @@ -221,13 +219,24 @@ func nodeSetToOrderedSlice(nodeSet graph.NodeSet) []*graph.Node { return nodes } -func listRelatedEntityType(ctx context.Context, db graph.Database, primaryDisplayKinds graphschema.PrimaryDisplayKinds, entityType, objectID string, skip, limit int) ([]azure.Node, int, error) { +type relatedEntityTypeOptions struct { + relatedEntityString string + sourceKind graph.Kind + sourceObjectID string + skip int + limit int +} + +func listRelatedEntityType(ctx context.Context, db graph.Database, primaryDisplayKinds graphschema.PrimaryDisplayKinds, options relatedEntityTypeOptions) ([]azure.Node, int, error) { var ( - nodeSet graph.NodeSet - err error + nodeSet graph.NodeSet + err error + objectID = options.sourceObjectID + skip = options.skip + limit = options.limit ) // NOTE: All skip/limit passed to lower level queries is currently hardcoded to 0 so we can get the full count of the dataset for skip/limit tracking - switch relatedEntityType := azure.RelatedEntityType(entityType); relatedEntityType { + switch relatedEntityType := azure.RelatedEntityType(options.relatedEntityString); relatedEntityType { case azure.RelatedEntityTypeDescendentUsers, azure.RelatedEntityTypeDescendentGroups, azure.RelatedEntityTypeDescendentManagementGroups, azure.RelatedEntityTypeDescendentSubscriptions, azure.RelatedEntityTypeDescendentResourceGroups, azure.RelatedEntityTypeDescendentVirtualMachines, @@ -239,7 +248,7 @@ func listRelatedEntityType(ctx context.Context, db graph.Database, primaryDispla azure.RelatedEntityTypeDescendentWebApps, azure.RelatedEntityTypeDescendentLogicApps, azure.RelatedEntityTypeDescendentFunctionApps, azure.RelatedEntityTypeDescendentAutomationAccounts: - if nodeSet, err = azure.ListEntityDescendents(ctx, db, relatedEntityType, objectID, 0, 0); err != nil { + if nodeSet, err = azure.ListEntityDescendents(ctx, db, relatedEntityType, options.sourceKind, objectID, 0, 0); err != nil { return nil, 0, err } case azure.RelatedEntityTypeActiveAssignments: @@ -332,7 +341,7 @@ func listRelatedEntityType(ctx context.Context, db graph.Database, primaryDispla return azure.FromGraphNodes(primaryDisplayKinds, s), nodeCount, nil } -func (s *Resources) GetAZRelatedEntities(ctx context.Context, response http.ResponseWriter, request *http.Request, objectID string) { +func (s *Resources) GetAZRelatedEntities(ctx context.Context, response http.ResponseWriter, request *http.Request, objectID string, sourceKind graph.Kind) { var ( queryParams = request.URL.Query() returnType = queryParams.Get(relatedEntityReturnTypeQueryParameterName) @@ -351,16 +360,28 @@ func (s *Resources) GetAZRelatedEntities(ctx context.Context, response http.Resp api.WriteErrorResponse(ctx, ErrBadQueryParameter(request, model.PaginationQueryParameterSkip, err), response) } else if limit, err := ParseLimitQueryParameter(queryParams, 100); err != nil { api.WriteErrorResponse(ctx, ErrBadQueryParameter(request, model.PaginationQueryParameterLimit, err), response) + } else if primaryDisplayKinds, err := s.DB.GetPrimaryDisplayKinds(ctx); err != nil { + api.HandleDatabaseError(request, response, err) } else if returnType == relatedEntityReturnTypeGraph { - if data, _, apiErr := graphRelatedEntityType(request, s.DB, s.Graph, relatedEntityType, objectID); apiErr != nil { + options := relatedEntityTypeOptions{ + relatedEntityString: relatedEntityType, + sourceObjectID: objectID, + sourceKind: sourceKind, + } + if data, _, apiErr := graphRelatedEntityType(request, s.Graph, primaryDisplayKinds, options); apiErr != nil { api.WriteErrorResponse(ctx, apiErr, response) } else { api.WriteJSONResponse(ctx, data, http.StatusOK, response) } - } else if primaryDisplayKinds, err := s.DB.GetPrimaryDisplayKinds(request.Context()); err != nil { - api.HandleDatabaseError(request, response, err) } else { - if nodes, count, err := listRelatedEntityType(ctx, s.Graph, primaryDisplayKinds, relatedEntityType, objectID, skip, limit); err != nil { + options := relatedEntityTypeOptions{ + relatedEntityString: relatedEntityType, + sourceObjectID: objectID, + sourceKind: sourceKind, + skip: skip, + limit: limit, + } + if nodes, count, err := listRelatedEntityType(ctx, s.Graph, primaryDisplayKinds, options); err != nil { if errors.Is(err, ErrParameterSkip) { api.WriteErrorResponse(ctx, api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf(utils.ErrorInvalidSkip, skip), request), response) } else if errors.Is(err, ErrParameterRelatedEntityType) { @@ -457,7 +478,7 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) } else if relatedEntityTypeStr := queryVars.Get(relatedEntityTypeQueryParameterName); relatedEntityTypeStr != "" { - s.GetAZRelatedEntities(request.Context(), response, request, objectID) + s.GetAZRelatedEntities(request.Context(), response, request, objectID, azKind) } else if includeCounts, err := api.ParseOptionalBool(queryVars.Get(api.QueryParameterIncludeCounts), true); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if entityInformation, err := GetAZEntityInformation(request.Context(), s.DB, s.Graph, entityType, objectID, includeCounts); err != nil { diff --git a/cmd/api/src/api/v2/azure_test.go b/cmd/api/src/api/v2/azure_test.go index 4b06521b488c..565e7c17990f 100644 --- a/cmd/api/src/api/v2/azure_test.go +++ b/cmd/api/src/api/v2/azure_test.go @@ -180,7 +180,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, expected: expected{ responseCode: http.StatusInternalServerError, - responseBody: `{"errors":[{"context":"","message":"error fetching primary display kinds: database error"}],"http_status":500,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseBody: `{"errors":[{"context":"","message":"an internal error has occurred that is preventing the service from servicing this request"}],"http_status":500,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, }, diff --git a/packages/go/analysis/azure/azure_integration_test.go b/packages/go/analysis/azure/azure_integration_test.go index 74ebeef1b571..efe3c323cbbe 100644 --- a/packages/go/analysis/azure/azure_integration_test.go +++ b/packages/go/analysis/azure/azure_integration_test.go @@ -949,3 +949,222 @@ func TestEntityDetails(t *testing.T) { }) }) } + +// TestFetchEntityDescendentPaths_DirectPathsToRoot verifies that each terminal node with a direct +// azure.Contains edge to the root tenant is included in the returned path set. +func TestFetchEntityDescendentPaths_DirectPathsToRoot(t *testing.T) { + t.Parallel() + + suite := setupIntegrationTestSuite(t) + defer teardownIntegrationTestSuite(t, &suite) + + var ( + tenantID = integration.RandomObjectID(t) + tenantNode = NewAzureTenant(t, &suite, tenantID) + app1 = NewAzureApplication(t, &suite, "App1", integration.RandomObjectID(t), tenantID) + app2 = NewAzureApplication(t, &suite, "App2", integration.RandomObjectID(t), tenantID) + ) + + NewRelationship(t, &suite, tenantNode, app1, graphAzure.Contains) + NewRelationship(t, &suite, tenantNode, app2, graphAzure.Contains) + + err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error { + paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App) + require.NoError(t, err) + + nodeIDs := paths.AllNodes().IDs() + require.Len(t, nodeIDs, 3) + require.Contains(t, nodeIDs, tenantNode.ID) + require.Contains(t, nodeIDs, app1.ID) + require.Contains(t, nodeIDs, app2.ID) + + return nil + }) + require.NoError(t, err) +} + +// TestFetchEntityDescendentPaths_MultiHopPathToRoot verifies that a terminal node connected to the +// root through one or more intermediate azure.Contains hops is fully represented in the path set, +// with every intermediary node included. +func TestFetchEntityDescendentPaths_MultiHopPathToRoot(t *testing.T) { + t.Parallel() + + suite := setupIntegrationTestSuite(t) + defer teardownIntegrationTestSuite(t, &suite) + + var ( + tenantID = integration.RandomObjectID(t) + tenantNode = NewAzureTenant(t, &suite, tenantID) + subscriptionNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{ + common.Name: "TestSubscription", + common.ObjectID: integration.RandomObjectID(t), + graphAzure.TenantID: tenantID, + }), graphAzure.Entity, graphAzure.Subscription) + appNode = NewAzureApplication(t, &suite, "App", integration.RandomObjectID(t), tenantID) + ) + + // tenant --Contains--> subscription --Contains--> app + NewRelationship(t, &suite, tenantNode, subscriptionNode, graphAzure.Contains) + NewRelationship(t, &suite, subscriptionNode, appNode, graphAzure.Contains) + + err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error { + paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App) + require.NoError(t, err) + + nodeIDs := paths.AllNodes().IDs() + require.Len(t, nodeIDs, 3) + require.Contains(t, nodeIDs, tenantNode.ID) + require.Contains(t, nodeIDs, subscriptionNode.ID) + require.Contains(t, nodeIDs, appNode.ID) + + return nil + }) + require.NoError(t, err) +} + +// TestFetchEntityDescendentPaths_NoTerminalsReturnsEmpty verifies that an empty path set is +// returned when no nodes of the requested kind exist within the tenant. +func TestFetchEntityDescendentPaths_NoTerminalsReturnsEmpty(t *testing.T) { + t.Parallel() + + suite := setupIntegrationTestSuite(t) + defer teardownIntegrationTestSuite(t, &suite) + + var ( + tenantID = integration.RandomObjectID(t) + tenantNode = NewAzureTenant(t, &suite, tenantID) + ) + + err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error { + paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App) + require.NoError(t, err) + require.Equal(t, 0, paths.Len()) + + return nil + }) + require.NoError(t, err) +} + +// TestFetchEntityDescendentPaths_SharedIntermediateNode verifies that two terminal nodes sharing +// an intermediate container node both produce complete paths reaching root. Each traversal +// independently reaches root through the shared intermediate without being truncated. +func TestFetchEntityDescendentPaths_SharedIntermediateNode(t *testing.T) { + t.Parallel() + + suite := setupIntegrationTestSuite(t) + defer teardownIntegrationTestSuite(t, &suite) + + var ( + tenantID = integration.RandomObjectID(t) + tenantNode = NewAzureTenant(t, &suite, tenantID) + subscriptionNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{ + common.Name: "SharedSubscription", + common.ObjectID: integration.RandomObjectID(t), + graphAzure.TenantID: tenantID, + }), graphAzure.Entity, graphAzure.Subscription) + app1 = NewAzureApplication(t, &suite, "App1", integration.RandomObjectID(t), tenantID) + app2 = NewAzureApplication(t, &suite, "App2", integration.RandomObjectID(t), tenantID) + ) + + // tenant --Contains--> subscription --Contains--> app1 + // --Contains--> app2 + NewRelationship(t, &suite, tenantNode, subscriptionNode, graphAzure.Contains) + NewRelationship(t, &suite, subscriptionNode, app1, graphAzure.Contains) + NewRelationship(t, &suite, subscriptionNode, app2, graphAzure.Contains) + + err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error { + paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App) + require.NoError(t, err) + + nodeIDs := paths.AllNodes().IDs() + require.Contains(t, nodeIDs, tenantNode.ID) + require.Contains(t, nodeIDs, subscriptionNode.ID) + require.Contains(t, nodeIDs, app1.ID) + require.Contains(t, nodeIDs, app2.ID) + + return nil + }) + require.NoError(t, err) +} + +// TestFetchEntityDescendentPaths_TerminalNotConnectedToRoot verifies that a terminal node +// which exists in the same tenant but has no azure.Contains path leading back to root +// is excluded from the returned path set. +func TestFetchEntityDescendentPaths_TerminalNotConnectedToRoot(t *testing.T) { + t.Parallel() + + suite := setupIntegrationTestSuite(t) + defer teardownIntegrationTestSuite(t, &suite) + + var ( + tenantID = integration.RandomObjectID(t) + tenantNode = NewAzureTenant(t, &suite, tenantID) + connectedApp = NewAzureApplication(t, &suite, "ConnectedApp", integration.RandomObjectID(t), tenantID) + disconnectedApp = NewAzureApplication(t, &suite, "DisconnectedApp", integration.RandomObjectID(t), tenantID) + ) + + // Only connectedApp has a Contains edge to root; disconnectedApp shares the tenantID + // but has no path leading back to tenantNode. + NewRelationship(t, &suite, tenantNode, connectedApp, graphAzure.Contains) + + err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error { + paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App) + require.NoError(t, err) + + nodeIDs := paths.AllNodes().IDs() + require.Contains(t, nodeIDs, tenantNode.ID) + require.Contains(t, nodeIDs, connectedApp.ID) + require.NotContains(t, nodeIDs, disconnectedApp.ID) + + return nil + }) + require.NoError(t, err) +} + +// TestListEntityDescendents_NestedManagementGroupToSubscription verifies that a Subscription +// nested under one or more intermediate ManagementGroups is returned when listing the +// DescendentSubscriptions of an ancestor ManagementGroup. +func TestListEntityDescendents_NestedManagementGroupToSubscription(t *testing.T) { + t.Parallel() + + suite := setupIntegrationTestSuite(t) + defer teardownIntegrationTestSuite(t, &suite) + + var ( + tenantID = integration.RandomObjectID(t) + mgRootObjectID = integration.RandomObjectID(t) + mgChildObjectID = integration.RandomObjectID(t) + subObjectID = integration.RandomObjectID(t) + tenantNode = NewAzureTenant(t, &suite, tenantID) + mgRootNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{ + common.Name: "MGRoot", + common.ObjectID: mgRootObjectID, + graphAzure.TenantID: tenantID, + }), graphAzure.Entity, graphAzure.ManagementGroup) + mgChildNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{ + common.Name: "MGChild", + common.ObjectID: mgChildObjectID, + graphAzure.TenantID: tenantID, + }), graphAzure.Entity, graphAzure.ManagementGroup) + nestedSubNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{ + common.Name: "NestedSubscription", + common.ObjectID: subObjectID, + graphAzure.TenantID: tenantID, + }), graphAzure.Entity, graphAzure.Subscription) + ) + + // tenant --Contains--> mgRoot --Contains--> mgChild --Contains--> nestedSub + NewRelationship(t, &suite, tenantNode, mgRootNode, graphAzure.Contains) + NewRelationship(t, &suite, mgRootNode, mgChildNode, graphAzure.Contains) + NewRelationship(t, &suite, mgChildNode, nestedSubNode, graphAzure.Contains) + + nodes, err := azure.ListEntityDescendents( + suite.Context, suite.GraphDB, + azure.RelatedEntityTypeDescendentSubscriptions, graphAzure.ManagementGroup, + mgRootObjectID, 0, 0, + ) + require.NoError(t, err) + + require.Equal(t, 1, len(nodes), "expected nested subscription to be returned as a descendent of mgRoot") + require.Contains(t, nodes.IDs(), nestedSubNode.ID) +} diff --git a/packages/go/analysis/azure/db_ops.go b/packages/go/analysis/azure/db_ops.go index 466fe69838f6..0466ff57943e 100644 --- a/packages/go/analysis/azure/db_ops.go +++ b/packages/go/analysis/azure/db_ops.go @@ -25,7 +25,7 @@ import ( "github.com/specterops/dawgs/ops" ) -func ListEntityDescendentPaths(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, objectID string) (graph.PathSet, error) { +func ListEntityDescendentPaths(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, sourceKind graph.Kind, objectID string) (graph.PathSet, error) { var paths graph.PathSet return paths, db.ReadTransaction(ctx, func(tx graph.Transaction) error { @@ -73,13 +73,17 @@ func ListEntityDescendentPaths(ctx context.Context, db graph.Database, relatedEn return ErrInvalidRelatedEntityType } - paths, err = FetchEntityDescendentPaths(tx, node, targetKind) + if isDirectDescendent(sourceKind, targetKind) { + paths, err = FetchDirectDescendentPaths(tx, node, targetKind) + } else { + paths, err = FetchEntityDescendentPaths(tx, node, targetKind) + } return err } }) } -func ListEntityDescendents(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, objectID string, skip, limit int) (graph.NodeSet, error) { +func ListEntityDescendents(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, sourceKind graph.Kind, objectID string, skip, limit int) (graph.NodeSet, error) { var nodes graph.NodeSet return nodes, db.ReadTransaction(ctx, func(tx graph.Transaction) error { @@ -127,7 +131,11 @@ func ListEntityDescendents(ctx context.Context, db graph.Database, relatedEntity return ErrInvalidRelatedEntityType } - nodes, err = FetchEntityDescendents(tx, node, skip, limit, targetKind) + if isDirectDescendent(sourceKind, targetKind) { + nodes, err = FetchDirectEntityDescendents(tx, node, targetKind) + } else { + nodes, err = FetchEntityDescendents(tx, node, targetKind) + } return err } }) diff --git a/packages/go/analysis/azure/management_group.go b/packages/go/analysis/azure/management_group.go index eb99b1af2654..013f4e15400d 100644 --- a/packages/go/analysis/azure/management_group.go +++ b/packages/go/analysis/azure/management_group.go @@ -45,7 +45,7 @@ func ManagementGroupEntityDetails(ctx context.Context, db graph.Database, primar func PopulateManagementGroupEntityDetailsCounts(tx graph.Transaction, node *graph.Node, details ManagementGroupDetails) (ManagementGroupDetails, error) { var descendentKinds = GetDescendentKinds(azure.ManagementGroup) - if descendents, err := FetchEntityDescendentCounts(tx, node, 0, 0, descendentKinds...); err != nil { + if descendents, err := FetchEntityDescendentCounts(tx, node, descendentKinds...); err != nil { return details, err } else { details.Descendents = descendents diff --git a/packages/go/analysis/azure/queries.go b/packages/go/analysis/azure/queries.go index 6faecec03167..cb5d61fd0b13 100644 --- a/packages/go/analysis/azure/queries.go +++ b/packages/go/analysis/azure/queries.go @@ -788,31 +788,160 @@ func FetchKeyVaultReaderCounts(tx graph.Transaction, keyVault *graph.Node) (KeyV return keyVaultReaders, nil } -func EntityDescendentsTraversal(root *graph.Node, _ ...graph.Kind) ops.TraversalPlan { - return ops.TraversalPlan{ - Root: root, - Direction: graph.DirectionOutbound, - BranchQuery: FilterContains, +// directRelationshipKinds maps each valid AZContains start node kind to the set of valid end node kinds +// where it is a single hop relationship as opposed to a path of multiple relationships. +// This is derived directly from the ingestion logic in bhce/packages/go/ein/azure.go. +var directRelationshipKinds = map[graph.Kind]graph.Kinds{ + azure.Tenant: { + azure.App, + azure.Device, + azure.Group, + azure.Role, + azure.ServicePrincipal, + azure.Subscription, + azure.User, + }, + azure.Subscription: { + azure.ResourceGroup, + }, + azure.ResourceGroup: { + azure.VMScaleSet, + azure.FunctionApp, + azure.KeyVault, + azure.LogicApp, + azure.VM, + azure.ManagedCluster, + azure.ContainerRegistry, + azure.WebApp, + azure.AutomationAccount, + }, +} + +// isDirectDescendent reports whether targetKind is a valid direct AZContains child of sourceKind, +// according to the containsValidEndKinds mapping derived from the Azure ingestion logic. +func isDirectDescendent(sourceKind, targetKind graph.Kind) bool { + if validEndKinds, ok := directRelationshipKinds[sourceKind]; ok && validEndKinds.ContainsOneOf(targetKind) { + return true } + return false } -func FetchEntityDescendentPaths(tx graph.Transaction, root *graph.Node, descendentKinds ...graph.Kind) (graph.PathSet, error) { - return ops.TraverseIntermediaryPaths(tx, EntityDescendentsTraversal(root, descendentKinds...), func(node *graph.Node) bool { - return node.Kinds.ContainsOneOf(descendentKinds...) - }) +// nodeAzureTenantID returns the Azure tenant ID for a node. For Tenant nodes the tenant ID is +// stored in the common.ObjectID property; for all other Azure nodes it is stored in azure.TenantID. +func nodeAzureTenantID(node *graph.Node) (string, error) { + if node.Kinds.ContainsOneOf(azure.Tenant) { + return node.Properties.Get(common.ObjectID.String()).String() + } + + return node.Properties.Get(azure.TenantID.String()).String() } -func FetchEntityDescendents(tx graph.Transaction, root *graph.Node, skip, limit int, descendentKinds ...graph.Kind) (graph.NodeSet, error) { - if paths, err := FetchEntityDescendentPaths(tx, root, descendentKinds...); err != nil { +// FetchDirectDescendentPaths fetches the direct AZContains relationships from the root node +func FetchDirectDescendentPaths(tx graph.Transaction, root *graph.Node, descendentKind ...graph.Kind) (graph.PathSet, error) { + return ops.FetchPathSet(tx.Relationships().Filter(query.And( + query.Equals(query.StartID(), root.ID), + query.Kind(query.Relationship(), azure.Contains), + query.KindIn(query.End(), descendentKind...), + ))) +} + +// FetchDirectEntityDescendents fetches the set of nodes of descendentKind that are direct AZContains +// children of root, excluding root itself. +func FetchDirectEntityDescendents(tx graph.Transaction, root *graph.Node, descendentKind graph.Kind) (graph.NodeSet, error) { + if paths, err := FetchDirectDescendentPaths(tx, root, descendentKind); err != nil { return nil, err } else { nodes := paths.AllNodes() nodes.Remove(root.ID) - return nodes.ContainingNodeKinds(descendentKinds...), nil + return nodes, nil + } +} + +// FetchDescendentKindByTenantID fetches the set of nodes matching descendentKind that reside within +// the same Azure tenant as root, determined by comparing the azure.TenantID property against root's +// tenant ID. +func FetchDescendentKindByTenantID(tx graph.Transaction, root *graph.Node, descendentKind ...graph.Kind) (graph.NodeSet, error) { + if tenantID, err := nodeAzureTenantID(root); err != nil { + return nil, err + } else if nodes, err := ops.FetchNodeSet(tx.Nodes().Filter(query.And( + query.KindIn(query.Node(), descendentKind...), + query.Equals(query.NodeProperty(azure.TenantID.String()), tenantID), + ))); err != nil { + return nil, err + } else { + return nodes, nil + } +} + +// FetchEntityDescendentPaths fetches paths from each terminal node of descendentKind (within the +// same Azure tenant as root) back up to root via azure.Contains relationships. Each traversal +// halts upon reaching root, and only paths that successfully reach root are included in the result. +func FetchEntityDescendentPaths(tx graph.Transaction, root *graph.Node, descendentKind ...graph.Kind) (graph.PathSet, error) { + pathSet := graph.NewPathSet() + + terminalNodes, err := FetchDescendentKindByTenantID(tx, root, descendentKind...) + if err != nil { + return nil, err + } + + for _, terminalNode := range terminalNodes { + reachedRoot := false + if paths, err := ops.TraversePaths(tx, ops.TraversalPlan{ + Root: terminalNode, + Direction: graph.DirectionInbound, + BranchQuery: func() graph.Criteria { + return query.Kind(query.Relationship(), azure.Contains) + }, + ExpansionFilter: func(segment *graph.PathSegment) bool { + if segment.Node.ID == root.ID { + reachedRoot = true + return false + } + return true + }, + }); err != nil { + return nil, err + } else { + if reachedRoot { + pathSet.AddPathSet(paths) + } + } + } + + return pathSet, nil +} + +func FetchEntityDescendents(tx graph.Transaction, root *graph.Node, descendentKind graph.Kind) (graph.NodeSet, error) { + if paths, err := FetchEntityDescendentPaths(tx, root, descendentKind); err != nil { + return nil, err + } else { + nodes := paths.AllNodes() + nodes.Remove(root.ID) + return nodes.ContainingNodeKinds(descendentKind), nil + } +} + +// FetchDirectDescendentCounts returns the per-kind counts of nodes that are direct AZContains +// children of root, keyed by kind string and excluding root itself. +func FetchDirectDescendentCounts(tx graph.Transaction, root *graph.Node, descendentKinds ...graph.Kind) (Descendents, error) { + if paths, err := FetchDirectDescendentPaths(tx, root, descendentKinds...); err != nil { + return Descendents{}, err + } else { + details := Descendents{ + DescendentCounts: map[string]int{}, + } + kindSet := paths.AllNodes().KindSet() + kindSet.RemoveNode(root.ID) + + for _, kind := range descendentKinds { + details.DescendentCounts[kind.String()] = int(kindSet.Count(kind)) + } + + return details, nil } } -func FetchEntityDescendentCounts(tx graph.Transaction, root *graph.Node, skip, limit int, descendentKinds ...graph.Kind) (Descendents, error) { +func FetchEntityDescendentCounts(tx graph.Transaction, root *graph.Node, descendentKinds ...graph.Kind) (Descendents, error) { if paths, err := FetchEntityDescendentPaths(tx, root, descendentKinds...); err != nil { return Descendents{}, err } else { diff --git a/packages/go/analysis/azure/resource_group.go b/packages/go/analysis/azure/resource_group.go index 4330bb44bfcc..3cf042faf11a 100644 --- a/packages/go/analysis/azure/resource_group.go +++ b/packages/go/analysis/azure/resource_group.go @@ -43,7 +43,7 @@ func ResourceGroupEntityDetails(ctx context.Context, db graph.Database, primaryD func PopulateResourceGroupEntityDetailsCounts(tx graph.Transaction, node *graph.Node, details ResourceGroupDetails) (ResourceGroupDetails, error) { var descendentKinds = GetDescendentKinds(azure.ResourceGroup) - if descendents, err := FetchEntityDescendentCounts(tx, node, 0, 0, descendentKinds...); err != nil { + if descendents, err := FetchDirectDescendentCounts(tx, node, descendentKinds...); err != nil { return details, err } else { details.Descendents = descendents diff --git a/packages/go/analysis/azure/subscription.go b/packages/go/analysis/azure/subscription.go index ad8106a7f040..7a1c320c494e 100644 --- a/packages/go/analysis/azure/subscription.go +++ b/packages/go/analysis/azure/subscription.go @@ -43,7 +43,7 @@ func SubscriptionEntityDetails(ctx context.Context, db graph.Database, primaryDi func PopulateSubscriptionEntityDetailsCounts(tx graph.Transaction, node *graph.Node, details SubscriptionDetails) (SubscriptionDetails, error) { var descendentKinds = GetDescendentKinds(azure.Subscription) - if descendents, err := FetchEntityDescendentCounts(tx, node, 0, 0, descendentKinds...); err != nil { + if descendents, err := FetchEntityDescendentCounts(tx, node, descendentKinds...); err != nil { return details, err } else { details.Descendents = descendents diff --git a/packages/go/analysis/azure/tenant.go b/packages/go/analysis/azure/tenant.go index cdf94f9e43ea..4e5bb0cba5d0 100644 --- a/packages/go/analysis/azure/tenant.go +++ b/packages/go/analysis/azure/tenant.go @@ -48,7 +48,7 @@ func TenantEntityDetails(ctx context.Context, db graph.Database, primaryDisplayK func PopulateTenantEntityDetailsCounts(tx graph.Transaction, node *graph.Node, details TenantDetails) (TenantDetails, error) { descendentKinds := GetDescendentKinds(azure.Tenant) - if descendents, err := FetchEntityDescendentCounts(tx, node, 0, 0, descendentKinds...); err != nil { + if descendents, err := FetchEntityDescendentCounts(tx, node, descendentKinds...); err != nil { return details, err } else { details.Descendents = descendents diff --git a/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.test.tsx b/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.test.tsx index ad60093e0dcd..cfe0f5a0576c 100644 --- a/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.test.tsx +++ b/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.test.tsx @@ -131,7 +131,7 @@ describe('EntityInfoDataTableGraphed', () => { expect(sum).not.toBeNull(); }); - it('displays ! icon when one of the Affected Object calls fail', async () => { + it('sums the remaining sections when one of the Affected Object calls fails', async () => { console.error = vi.fn(); server.use( rest.get(`api/v2/gpos/${objectId}/ous`, (req, res, ctx) => { @@ -141,9 +141,10 @@ describe('EntityInfoDataTableGraphed', () => { render(); - const errorIcon = await screen.findByTestId('ErrorOutlineIcon'); + const sum = await screen.findByText('5,056'); + expect(sum).not.toBeNull(); - expect(errorIcon).not.toBeNull(); + expect(screen.queryByTestId('ErrorOutlineIcon')).toBeNull(); }); it('displays 0 when a given sections returns empty, and sums the rest of the sections correctly', async () => { diff --git a/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.tsx b/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.tsx index 1a257bc52748..49495852ed14 100644 --- a/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.tsx +++ b/packages/javascript/bh-shared-ui/src/components/EntityInfoDataTableGraphed/EntityInfoDataTableGraphed.tsx @@ -13,6 +13,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +import { PaginatedResponse } from 'js-client-library'; import { useQuery } from 'react-query'; import { NODE_GRAPH_RENDER_LIMIT } from '../../constants'; import { useExploreParams } from '../../hooks'; @@ -21,6 +22,27 @@ import { EntityInfoDataTableProps, entityRelationshipEndpoints } from '../../uti import EntityInfoCollapsibleSection from '../EntityInfo/EntityInfoCollapsibleSection'; import InfiniteScrollingTable from '../InfiniteScrollingTable'; +function getCount( + queryData: Array>> | PaginatedResponse | undefined, + countLabel: string | undefined +): number | undefined { + if (Array.isArray(queryData)) { + const fulfilledData = queryData.filter((result) => result.status === 'fulfilled').map((result) => result.value); + + if (countLabel !== undefined) { + const labeledSection = fulfilledData.find((sectionData: any) => sectionData?.countLabel === countLabel); + return labeledSection?.count; + } else { + return fulfilledData.reduce((acc, val) => { + const sectionCount = val?.count ?? 0; + return acc + sectionCount; + }, 0); + } + } else if (queryData) { + return queryData?.count ?? 0; + } +} + export const EntityInfoDataTableGraphed: React.FC = ({ id, label, @@ -35,13 +57,13 @@ export const EntityInfoDataTableGraphed: React.FC = ({ const isExpandedPanelSection = (expandedPanelSections as string[]).includes(label); const countQuery = useQuery( - ['relatedCount', label, id], + ['relatedCount', label, id, sections], () => { if (endpoint) { return endpoint({ id, skip: 0, limit: 128 }); } if (sections) - return Promise.all( + return Promise.allSettled( sections.map((section: EntityInfoDataTableProps) => { const endpoint = section.queryType ? entityRelationshipEndpoints[section.queryType] : undefined; return endpoint ? endpoint({ id, skip: 0, limit: 128 }) : Promise.resolve(); @@ -104,21 +126,7 @@ export const EntityInfoDataTableGraphed: React.FC = ({ setNodeSearchParams(item); }; - let count: number | undefined; - if (Array.isArray(countQuery.data)) { - if (countLabel !== undefined) { - countQuery.data.forEach((sectionData: any) => { - if (sectionData.countLabel === countLabel) count = sectionData.count; - }); - } else { - count = countQuery.data.reduce((acc, val) => { - const count = val?.count ?? 0; - return acc + count; - }, 0); - } - } else if (countQuery.data) { - count = countQuery.data?.count ?? 0; - } + const count = getCount(countQuery.data, countLabel); return (