Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions cmd/api/src/api/bloodhoundgraph/bloodhoundgraph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"testing"

"github.com/specterops/bloodhound/packages/go/graphschema"
"github.com/specterops/bloodhound/packages/go/graphschema/common"
"github.com/specterops/dawgs/graph"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -136,3 +137,30 @@ func TestSetFontIcon(t *testing.T) {
assert.Nil(t, node.FontIcon)
})
}

func TestNodeToBloodHoundGraph_PrivilegeZonesUseStandardName(t *testing.T) {
t.Parallel()

node := &graph.Node{
Kinds: graph.Kinds{graph.StringKind("PZ_PrivilegeZoneEnvironment")},
Properties: graph.AsProperties(map[string]any{
common.Name.String(): "TIER ZERO IN PHANTOM.CORP",
common.DisplayName.String(): "Tier Zero in PHANTOM.CORP",
common.ObjectID.String(): "pz:1:env",
}),
}

result := NodeToBloodHoundGraph(nil, node)

require.NotNil(t, result.Label)
require.Equal(t, "TIER ZERO IN PHANTOM.CORP", result.Label.Text)
}

func TestRelationshipToBloodHoundGraph_PrivilegeZoneLabel(t *testing.T) {
t.Parallel()

result := RelationshipToBloodHoundGraph(&graph.Relationship{Kind: graph.StringKind("PZ_InZone")})

require.NotNil(t, result.Label)
require.Equal(t, "In Zone", result.Label.Text)
}
13 changes: 12 additions & 1 deletion cmd/api/src/api/bloodhoundgraph/conversions.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ func RelationshipToBloodHoundGraph(rel *graph.Relationship) BloodHoundGraphLink
Data: relProperties,
},
Label: &BloodHoundGraphLinkLabel{
Text: rel.Kind.String(),
Text: relationshipDisplayName(rel.Kind.String()),
},
End2: &BloodHoundGraphLinkEnd{
Arrow: true,
Expand All @@ -79,6 +79,17 @@ func RelationshipToBloodHoundGraph(rel *graph.Relationship) BloodHoundGraphLink
}
}

func relationshipDisplayName(kind string) string {
switch kind {
case "PZ_InZone":
return "In Zone"
case "PZ_PartOfZone":
return "Part Of Zone"
default:
return kind
}
}

func PathSetToBloodHoundGraph(graphSchemaNodeValidDisplayKinds graphschema.PrimaryDisplayKinds, paths graph.PathSet) map[string]any {
result := make(map[string]any)

Expand Down
23 changes: 20 additions & 3 deletions cmd/api/src/api/v2/etac.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,21 +142,38 @@ func filterETACGraph(graphResponse model.UnifiedGraph, user model.User) (model.U

filteredResponse := model.UnifiedGraph{}
filteredNodes := make(map[string]model.UnifiedNode)
accessibleNodeIDs := make(map[string]struct{})

environmentKeys := []string{ad.DomainSID.String(), azure.TenantID.String(), graphschema.EnvironmentIDKey}

// filter nodes based on environment access
// Resolve directly accessible environment-scoped nodes first.
for id, node := range graphResponse.Nodes {
include := false
for _, key := range environmentKeys {
if val, ok := node.Properties[key]; ok {
if envStr, ok := val.(string); ok && slices.Contains(accessList, envStr) {
include = true
accessibleNodeIDs[id] = struct{}{}
break
}
}
}
}

// Canonical Privilege Zone nodes span environments. Expose one only when this
// response also contains an authorized environment-specific zone connected to it.
for _, edge := range graphResponse.Edges {
if edge.Kind != "PZ_PartOfZone" {
continue
}
if _, accessible := accessibleNodeIDs[edge.Source]; accessible {
if node, present := graphResponse.Nodes[edge.Target]; present && slices.Contains(node.Kinds, "PZ_PrivilegeZone") {
accessibleNodeIDs[edge.Target] = struct{}{}
}
}
}

// Filter nodes based on resolved environment access.
for id, node := range graphResponse.Nodes {
_, include := accessibleNodeIDs[id]
if include {
// user has access, we keep original node
filteredNodes[id] = node
Expand Down
76 changes: 76 additions & 0 deletions cmd/api/src/api/v2/etac_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright 2026 Specter Ops, Inc.
// SPDX-License-Identifier: Apache-2.0

package v2

import (
"testing"

"github.com/specterops/bloodhound/cmd/api/src/model"
"github.com/specterops/bloodhound/packages/go/graphschema"
"github.com/specterops/dawgs/graph"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPrivilegeZoneEnvironmentNodeETAC(t *testing.T) {
t.Parallel()

node := graph.PrepareNode(graph.AsProperties(map[string]any{
graphschema.EnvironmentIDKey: "allowed-environment",
}), graph.StringKind("PZ_PrivilegeZoneEnvironment"))

assert.False(t, nodeGatedByETAC([]string{"allowed-environment"}, node))
assert.True(t, nodeGatedByETAC([]string{"other-environment"}, node))
}

func TestFilterETACGraphPrivilegeZoneCanonicalVisibility(t *testing.T) {
t.Parallel()

graphResponse := model.UnifiedGraph{
Nodes: map[string]model.UnifiedNode{
"allowed": {
Kinds: []string{"PZ_PrivilegeZoneEnvironment"},
Properties: map[string]any{graphschema.EnvironmentIDKey: "allowed-environment"},
},
"denied": {
Kinds: []string{"PZ_PrivilegeZoneEnvironment"},
Properties: map[string]any{graphschema.EnvironmentIDKey: "denied-environment"},
},
"canonical": {Kinds: []string{"PZ_PrivilegeZone"}, Properties: map[string]any{}},
},
Edges: []model.UnifiedEdge{
{Source: "allowed", Target: "canonical", Kind: "PZ_PartOfZone", Label: "Part Of Zone"},
{Source: "denied", Target: "canonical", Kind: "PZ_PartOfZone", Label: "Part Of Zone"},
},
}
user := model.User{EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{{EnvironmentID: "allowed-environment"}}}

filtered, err := filterETACGraph(graphResponse, user)
require.NoError(t, err)
assert.False(t, filtered.Nodes["allowed"].Hidden)
assert.True(t, filtered.Nodes["denied"].Hidden)
assert.False(t, filtered.Nodes["canonical"].Hidden)
assert.Equal(t, "PZ_PartOfZone", filtered.Edges[0].Kind)
assert.Equal(t, "HIDDEN", filtered.Edges[1].Kind)
}

func TestFilterETACGraphHidesCanonicalZoneWithoutAuthorizedEnvironment(t *testing.T) {
t.Parallel()

graphResponse := model.UnifiedGraph{
Nodes: map[string]model.UnifiedNode{
"denied": {
Kinds: []string{"PZ_PrivilegeZoneEnvironment"},
Properties: map[string]any{graphschema.EnvironmentIDKey: "denied-environment"},
},
"canonical": {Kinds: []string{"PZ_PrivilegeZone"}, Properties: map[string]any{}},
},
Edges: []model.UnifiedEdge{{Source: "denied", Target: "canonical", Kind: "PZ_PartOfZone"}},
}
user := model.User{EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{{EnvironmentID: "allowed-environment"}}}

filtered, err := filterETACGraph(graphResponse, user)
require.NoError(t, err)
assert.True(t, filtered.Nodes["canonical"].Hidden)
}
46 changes: 46 additions & 0 deletions cmd/api/src/api/v2/search_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,31 @@ func Test_filterAndFormatSearchResults_default(t *testing.T) {
require.Equal(t, expectedDistinguishedName, actual[0].DistinguishedName)
}

func Test_filterAndFormatSearchResults_PrivilegeZonesUseStandardName(t *testing.T) {
t.Parallel()

for _, kind := range []graph.Kind{
graph.StringKind("PZ_PrivilegeZone"),
graph.StringKind("PZ_PrivilegeZoneEnvironment"),
} {
t.Run(kind.String(), func(t *testing.T) {
node := &graph.Node{
Kinds: graph.Kinds{kind},
Properties: graph.AsProperties(map[string]any{
common.Name.String(): "TIER ZERO IN PHANTOM.CORP",
common.DisplayName.String(): "Tier Zero in PHANTOM.CORP",
common.ObjectID.String(): "pz:test",
}),
}

results := filterAndFormatSearchResults([]*graph.Node{node}, nil, nil)

require.Len(t, results, 1)
require.Equal(t, "TIER ZERO IN PHANTOM.CORP", results[0].Name)
})
}
}

func Test_filterAndFormatSearchResults_includeOpenGraphNodes(t *testing.T) {
var (
customKind = "CustomKind"
Expand Down Expand Up @@ -394,6 +419,27 @@ func Test_filterAndFormatSearchResults_filterEnvironmentsOG(t *testing.T) {
require.Equal(t, "objectid3", actual[0].ObjectID)
}

func TestFilterAndFormatSearchResultsPrivilegeZoneEnvironmentETAC(t *testing.T) {
t.Parallel()

node := &graph.Node{
ID: 4,
Kinds: graph.Kinds{graph.StringKind("PZ_PrivilegeZoneEnvironment")},
Properties: graph.AsProperties(map[string]any{
common.ObjectID.String(): "pze:1:allowed",
common.Name.String(): "TIER ZERO IN ALLOWED",
graphschema.EnvironmentIDKey: "allowed-environment",
}),
}

allowed := filterAndFormatSearchResults([]*graph.Node{node}, []string{"allowed-environment"}, nil)
denied := filterAndFormatSearchResults([]*graph.Node{node}, []string{"other-environment"}, nil)

require.Len(t, allowed, 1)
assert.Equal(t, "pze:1:allowed", allowed[0].ObjectID)
assert.Empty(t, denied)
}

func Test_getSearchableNodeKinds(t *testing.T) {
tests := []struct {
name string
Expand Down
20 changes: 17 additions & 3 deletions cmd/api/src/model/unified_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ type UnifiedEdge struct {
Properties map[string]any `json:"properties,omitempty"`
}

func getRelationshipDisplayName(kind string) string {
switch kind {
case "PZ_InZone":
return "In Zone"
case "PZ_PartOfZone":
return "Part Of Zone"
default:
return kind
}
}

func FromDAWGSNode(primaryDisplayKinds graphschema.PrimaryDisplayKinds, node *graph.Node, includeProperties bool) UnifiedNode {
var (
props = node.Properties
Expand Down Expand Up @@ -109,7 +120,10 @@ func FromDAWGSNode(primaryDisplayKinds graphschema.PrimaryDisplayKinds, node *gr
// This is being used with slices.Map so it is necessary to return a closure
func FromDAWGSRelationship(includeProperties bool) func(*graph.Relationship) UnifiedEdge {
return func(rel *graph.Relationship) UnifiedEdge {
var properties map[string]any
var (
properties map[string]any
relationship = rel.Kind.String()
)

if includeProperties {
properties = rel.Properties.Map
Expand All @@ -119,8 +133,8 @@ func FromDAWGSRelationship(includeProperties bool) func(*graph.Relationship) Uni
ID: rel.ID.String(),
Source: rel.StartID.String(),
Target: rel.EndID.String(),
Kind: rel.Kind.String(),
Label: rel.Kind.String(),
Kind: relationship,
Label: getRelationshipDisplayName(relationship),
LastSeen: getTypedPropertyOrDefault(rel.Properties, common.LastSeen.String(), time.Now()),
Properties: properties,
}
Expand Down
46 changes: 46 additions & 0 deletions cmd/api/src/model/unified_graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,49 @@ func TestUnifiedGraph_AddPathSet(t *testing.T) {
require.Equal(t, len(testGraph.Edges), len(testGraph.Edges))
})
}

func TestFromDAWGSRelationship(t *testing.T) {
testCases := []struct {
name string
kind string
expectedLabel string
}{
{name: "privilege zone membership", kind: "PZ_InZone", expectedLabel: "In Zone"},
{name: "privilege zone rollup", kind: "PZ_PartOfZone", expectedLabel: "Part Of Zone"},
{name: "unmapped relationship", kind: "CustomEdge", expectedLabel: "CustomEdge"},
}

for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
relationship := &graph.Relationship{
ID: 1,
StartID: 2,
EndID: 3,
Kind: graph.StringKind(testCase.kind),
Properties: graph.NewProperties(),
}

result := FromDAWGSRelationship(false)(relationship)

require.Equal(t, testCase.kind, result.Kind)
require.Equal(t, testCase.expectedLabel, result.Label)
})
}
}

func TestFromDAWGSNode_PrivilegeZonesUseStandardName(t *testing.T) {
t.Parallel()

node := &graph.Node{
Kinds: graph.Kinds{graph.StringKind("PZ_PrivilegeZone")},
Properties: graph.AsProperties(map[string]any{
common.Name.String(): "TIER ZERO",
common.DisplayName.String(): "Tier Zero",
common.ObjectID.String(): "pz:1",
}),
}

result := FromDAWGSNode(nil, node, false)

require.Equal(t, "TIER ZERO", result.Label)
}
40 changes: 40 additions & 0 deletions cmd/api/src/queries/graph_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,46 @@ func TestSearchByNameOrObjectID_UseRawObjectID_StartsWith_NameCasing(t *testing.
})
}

func TestPrivilegeZoneSearchWithNormalizedName(t *testing.T) {
var (
testSuite = setupGraphDb(t)
graphQuery = queries.NewGraphQuery(testSuite.GraphDB, cache.Cache{}, config.Configuration{})
zoneKind = graph.StringKind("PZ_PrivilegeZone")
)
defer teardownIntegrationTestSuite(t, &testSuite)

err := testSuite.GraphDB.WriteTransaction(testSuite.Context, func(tx graph.Transaction) error {
_, err := tx.CreateNode(graph.AsProperties(graph.PropertyMap{
common.Name: "TIER ZERO",
common.DisplayName: "Tier Zero",
common.ObjectID: "pz:1",
}), zoneKind)
return err
})
require.NoError(t, err)

t.Run("Explore search finds mixed-case fuzzy term", func(t *testing.T) {
results, err := graphQuery.SearchNodesByNameOrObjectId(testSuite.Context, nil, "Tier Ze", 0, 10, false)
require.NoError(t, err)
require.Len(t, results, 1)
require.True(t, results[0].Kinds.ContainsOneOf(zoneKind))
})

t.Run("pathfinding search finds mixed-case exact term", func(t *testing.T) {
results, err := graphQuery.SearchByNameOrObjectID(testSuite.Context, true, false, "Tier Zero", queries.SearchTypeExact)
require.NoError(t, err)
require.Len(t, results, 1)
require.True(t, results.Slice()[0].Kinds.ContainsOneOf(zoneKind))
})

t.Run("pathfinding search finds mixed-case fuzzy term", func(t *testing.T) {
results, err := graphQuery.SearchByNameOrObjectID(testSuite.Context, true, false, "Tier Ze", queries.SearchTypeFuzzy)
require.NoError(t, err)
require.Len(t, results, 1)
require.True(t, results.Slice()[0].Kinds.ContainsOneOf(zoneKind))
})
}

func TestGetEntityResults(t *testing.T) {
dbInst := integration.SetupDB(t)
testContext := integration.NewGraphTestContext(t, schema.DefaultGraphSchema())
Expand Down
Loading
Loading