diff --git a/cmd/api/src/api/bloodhoundgraph/bloodhoundgraph_test.go b/cmd/api/src/api/bloodhoundgraph/bloodhoundgraph_test.go index 2cb5939e6444..e4873cc70bfd 100644 --- a/cmd/api/src/api/bloodhoundgraph/bloodhoundgraph_test.go +++ b/cmd/api/src/api/bloodhoundgraph/bloodhoundgraph_test.go @@ -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" @@ -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) +} diff --git a/cmd/api/src/api/bloodhoundgraph/conversions.go b/cmd/api/src/api/bloodhoundgraph/conversions.go index 11ff1f64ee8c..4391c7e0a41c 100644 --- a/cmd/api/src/api/bloodhoundgraph/conversions.go +++ b/cmd/api/src/api/bloodhoundgraph/conversions.go @@ -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, @@ -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) diff --git a/cmd/api/src/api/v2/etac.go b/cmd/api/src/api/v2/etac.go index 221588c5f046..099d7862f180 100644 --- a/cmd/api/src/api/v2/etac.go +++ b/cmd/api/src/api/v2/etac.go @@ -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 diff --git a/cmd/api/src/api/v2/etac_internal_test.go b/cmd/api/src/api/v2/etac_internal_test.go new file mode 100644 index 000000000000..cdddb9fa6802 --- /dev/null +++ b/cmd/api/src/api/v2/etac_internal_test.go @@ -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) +} diff --git a/cmd/api/src/api/v2/search_internal_test.go b/cmd/api/src/api/v2/search_internal_test.go index 089cea904a64..81156910787f 100644 --- a/cmd/api/src/api/v2/search_internal_test.go +++ b/cmd/api/src/api/v2/search_internal_test.go @@ -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" @@ -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 diff --git a/cmd/api/src/model/unified_graph.go b/cmd/api/src/model/unified_graph.go index dea2c12ea2fc..0ef497274781 100644 --- a/cmd/api/src/model/unified_graph.go +++ b/cmd/api/src/model/unified_graph.go @@ -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 @@ -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 @@ -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, } diff --git a/cmd/api/src/model/unified_graph_test.go b/cmd/api/src/model/unified_graph_test.go index 3fc5c2680ead..792fd2e1245b 100644 --- a/cmd/api/src/model/unified_graph_test.go +++ b/cmd/api/src/model/unified_graph_test.go @@ -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) +} diff --git a/cmd/api/src/queries/graph_integration_test.go b/cmd/api/src/queries/graph_integration_test.go index 4026c63377fe..afd428e2bea1 100644 --- a/cmd/api/src/queries/graph_integration_test.go +++ b/cmd/api/src/queries/graph_integration_test.go @@ -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()) diff --git a/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.test.tsx b/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.test.tsx index ce72f8a58970..827009e93022 100644 --- a/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.test.tsx +++ b/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.test.tsx @@ -83,4 +83,30 @@ describe('EntityObjectInformation', () => { consoleErrorSpy.mockRestore(); } }); + + it('uses privilege zone labels only for privilege zone nodes', async () => { + const selectedNode: NodeDetails = { + node_id: 3, + kinds: [{ name: 'PZ_PrivilegeZoneEnvironment', node_kind_id: 2 }], + properties: { objectid: 'pz-environment', environment_name: 'contoso.local', member_count: 42 }, + }; + + render(); + + expect(await screen.findByText('Environment:')).toBeInTheDocument(); + expect(screen.getByText('Members in environment:')).toBeInTheDocument(); + }); + + it('keeps generic labels for unrelated nodes', async () => { + const selectedNode: NodeDetails = { + node_id: 4, + kinds: [{ name: ActiveDirectoryNodeKind.User, node_kind_id: 1 }], + properties: { objectid: 'generic-user', environment_name: 'contoso.local', member_count: 42 }, + }; + + render(); + + expect(await screen.findByText('Environment Name:')).toBeInTheDocument(); + expect(screen.getByText('Member Count:')).toBeInTheDocument(); + }); }); diff --git a/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.tsx b/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.tsx index 396b7af61099..71ff84607f8b 100644 --- a/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.tsx +++ b/packages/javascript/bh-shared-ui/src/components/EntityInfo/EntityObjectInformation.tsx @@ -17,7 +17,7 @@ import { NodeDetails, NodeDetailsWithInfo } from 'js-client-library'; import { useEffect } from 'react'; import { kindObjectsToKindNames, useExploreParams, usePreviousValue, usePrimaryKind, useTagsQuery } from '../../hooks'; import { getZoneNameFromKinds } from '../../hooks/useAssetGroupTags'; -import { EntityField, formatObjectInfoFields } from '../../utils'; +import { EntityField, formatObjectInfoFields, privilegeZonePropertyDisplayNames } from '../../utils'; import { BasicObjectInfoFields } from '../../views/Explore/BasicObjectInfoFields'; import { SearchValue } from '../../views/Explore/ExploreSearch'; import { FieldsContainer, ObjectInfoFields } from '../../views/Explore/fragments'; @@ -51,7 +51,13 @@ export default function EntityObjectInformation({ selectedNode }: EntityObjectIn setIsObjectInfoPanelOpen(!isObjectInfoPanelOpen); }; - const formattedObjectFields: EntityField[] = formatObjectInfoFields(selectedNode.properties); + const isPrivilegeZoneNode = kindNames.some((kind) => + ['PZ_PrivilegeZone', 'PZ_PrivilegeZoneEnvironment'].includes(kind) + ); + const formattedObjectFields: EntityField[] = formatObjectInfoFields( + selectedNode.properties, + isPrivilegeZoneNode ? privilegeZonePropertyDisplayNames : undefined + ); const handleSourceNodeSelected = (sourceNode: SearchValue) => { setExploreParams({ primarySearch: sourceNode.objectid, searchType: 'node' }); diff --git a/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.test.ts b/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.test.ts index 1651573b6abf..b52efa9cfa58 100644 --- a/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.test.ts +++ b/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.test.ts @@ -31,12 +31,44 @@ import { formatDateString, formatList, formatNumber, + formatPotentiallyUnknownLabel, formatPrimitive, + formatRelationshipKind, getEntityName, NoEntitySelectedHeader, + privilegeZonePropertyDisplayNames, validateProperty, } from './entityInfoDisplay'; +describe('Formatting privilege zone graph labels', () => { + it.each([ + ['PZ_InZone', 'In Zone'], + ['PZ_PartOfZone', 'Part Of Zone'], + ['CustomEdge', 'CustomEdge'], + ])('formats relationship kind %s as %s', (kind, expected) => { + expect(formatRelationshipKind(kind)).toBe(expected); + }); + + it.each([ + ['environment_name', 'Environment Name'], + ['member_count', 'Member Count'], + ['custom_property', 'Custom Property'], + ])('keeps generic property %s formatted as %s', (property, expected) => { + expect(formatPotentiallyUnknownLabel(property)).toBe(expected); + }); + + it.each([ + ['relationship', 'Relationship'], + ['source_object', 'Source object'], + ['zone_name', 'Zone'], + ['environment_name', 'Environment'], + ['member_count', 'Members in environment'], + ['source', 'Source'], + ])('formats privilege zone property %s as %s', (property, expected) => { + expect(formatPotentiallyUnknownLabel(property, privilegeZonePropertyDisplayNames)).toBe(expected); + }); +}); + describe('Handling value formatting for Active Directory entity properties lastlogon, lastlogontimestamp, whencreated, and pwdlastset', () => { test('whencreated', () => { expect(formatADSpecificTime(-1, ADSpecificTimeProperties.WHEN_CREATED)).toEqual(AD_UNKNOWN_VALUE); @@ -186,4 +218,16 @@ describe('Evaluating the entity display name from a given entity', () => { }) ).toBe('foo'); }); + it.each(['PZ_PrivilegeZone', 'PZ_PrivilegeZoneEnvironment'])( + 'should use the standard name for %s entities', + (kind) => { + expect( + getEntityName({ + node_id: 1, + kinds: [{ name: kind, node_kind_id: 1 }], + properties: { name: 'TIER ZERO', displayname: 'Tier Zero' }, + }) + ).toBe('TIER ZERO'); + } + ); }); diff --git a/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.ts b/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.ts index 48e39f46c31f..5faa88743a52 100644 --- a/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.ts +++ b/packages/javascript/bh-shared-ui/src/utils/entityInfoDisplay.ts @@ -36,13 +36,32 @@ import { MappedStringLiteral } from '../types'; import { EntityKinds } from './content'; import { LuxonFormat } from './datetime'; -export const formatPotentiallyUnknownLabel = (propKey: string) => { +const privilegeZoneRelationshipDisplayNames: Record = { + PZ_InZone: 'In Zone', + PZ_PartOfZone: 'Part Of Zone', +}; + +export const privilegeZonePropertyDisplayNames: Record = { + relationship: 'Relationship', + source_object: 'Source object', + zone_name: 'Zone', + environment_name: 'Environment', + member_count: 'Members in environment', + source: 'Source', +}; + +export const formatRelationshipKind = (kind: string): string => privilegeZoneRelationshipDisplayNames[kind] ?? kind; + +export const formatPotentiallyUnknownLabel = (propKey: string, propertyDisplayNames: Record = {}) => { const { kind, isKnownProperty } = validateProperty(propKey); - return isKnownProperty ? getFieldLabel(kind!, propKey) : `${startCase(propKey)}`; + return isKnownProperty ? getFieldLabel(kind!, propKey) : propertyDisplayNames[propKey] ?? `${startCase(propKey)}`; }; -export const formatObjectInfoFields = (props: any): EntityField[] => { +export const formatObjectInfoFields = ( + props: any, + propertyDisplayNames: Record = {} +): EntityField[] => { let mappedFields: EntityField[] = []; const propKeys = Object.keys(props || {}); @@ -65,7 +84,7 @@ export const formatObjectInfoFields = (props: any): EntityField[] => { mappedFields.push({ kind: kind, - label: `${formatPotentiallyUnknownLabel(key)}:`, + label: `${formatPotentiallyUnknownLabel(key, propertyDisplayNames)}:`, value: value, keyprop: key, }); diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx index 9c16f531a7f5..b80a666e3183 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeInfoPane.tsx @@ -16,7 +16,7 @@ import { RelationshipDetails, RelationshipDetailsWithInfo } from 'js-client-library'; import React, { HTMLProps } from 'react'; import { RoleBasedFilterBadge } from '../../../components/RoleBasedFilterBadge'; -import { cn } from '../../../utils'; +import { cn, formatRelationshipKind } from '../../../utils'; import { ObjectInfoPanelContextProvider } from '../providers'; import EdgeInfoContent from './EdgeInfoContent'; import Header from './EdgeInfoHeader'; @@ -37,7 +37,7 @@ const EdgeInfoPane: React.FC = ({ className, selectedEdge }) <>
-
+
diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.test.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.test.tsx index 7098d154ddae..762fa41f163c 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.test.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.test.tsx @@ -38,9 +38,9 @@ const mockTargetNode: NodeDetails = { properties: { objectid: 'target-objectid', name: 'target_node', lastSeen: '2023-09-07T11:10:33.664596893Z' }, }; -const EdgeObjectInformationWithProvider = () => ( +const EdgeObjectInformationWithProvider = ({ edge = selectedEdge }: { edge?: RelationshipDetails }) => ( - + ); @@ -59,4 +59,31 @@ describe('EdgeObjectInformation', () => { expect(screen.getByText(/FALSE/)).toBeInTheDocument(); expect(screen.getByText(/Last Seen by BloodHound:/)).toBeInTheDocument(); }); + + test('uses privilege zone labels only for privilege zone relationships', async () => { + const edge: RelationshipDetails = { + ...selectedEdge, + relationship_id: 2, + kind: { name: 'PZ_InZone', relationship_kind_id: 2 }, + properties: { ...selectedEdge.properties, environment_name: 'contoso.local', member_count: 42 }, + }; + + render(); + + expect(await screen.findByText('Environment:')).toBeInTheDocument(); + expect(screen.getByText('Members in environment:')).toBeInTheDocument(); + }); + + test('keeps generic labels for unrelated relationships', async () => { + const edge: RelationshipDetails = { + ...selectedEdge, + relationship_id: 3, + properties: { ...selectedEdge.properties, environment_name: 'contoso.local', member_count: 42 }, + }; + + render(); + + expect(await screen.findByText('Environment Name:')).toBeInTheDocument(); + expect(screen.getByText('Member Count:')).toBeInTheDocument(); + }); }); diff --git a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx index 49a2022832fa..517e2a466331 100644 --- a/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx +++ b/packages/javascript/bh-shared-ui/src/views/Explore/EdgeInfo/EdgeObjectInformation.tsx @@ -16,7 +16,7 @@ import { NodeDetails, RelationshipDetails } from 'js-client-library'; import { FC, useEffect } from 'react'; import { usePreviousValue } from '../../../hooks'; -import { EntityField, formatObjectInfoFields } from '../../../utils'; +import { EntityField, formatObjectInfoFields, privilegeZonePropertyDisplayNames } from '../../../utils'; import { FieldsContainer, ObjectInfoFields } from '../fragments'; import { useObjectInfoPanelContext } from '../providers'; import EdgeInfoCollapsibleSection from './EdgeInfoCollapsibleSection'; @@ -48,12 +48,16 @@ const EdgeObjectInformation: FC = ({ selectedEdge, s value: targetNode?.properties.name || targetNode?.properties.objectid || '', }; + const isPrivilegeZoneRelationship = ['PZ_InZone', 'PZ_PartOfZone'].includes(selectedEdge.kind.name); const formattedObjectFields: EntityField[] = [ sourceNodeField, targetNodeField, - ...formatObjectInfoFields({ - ...selectedEdge.properties, - }), + ...formatObjectInfoFields( + { + ...selectedEdge.properties, + }, + isPrivilegeZoneRelationship ? privilegeZonePropertyDisplayNames : undefined + ), ]; const sectionLabel = 'Relationship Information';