Skip to content
Open
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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ require (
github.com/klauspost/compress v1.18.5 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions packages/go/analysis/agt.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"sync/atomic"
"time"

"github.com/prometheus/client_golang/prometheus"
"github.com/specterops/bloodhound/cmd/api/src/database"
"github.com/specterops/bloodhound/cmd/api/src/database/types/null"
"github.com/specterops/bloodhound/cmd/api/src/model"
Expand Down Expand Up @@ -856,6 +857,9 @@ func tagAssetGroupNodesForTag(ctx context.Context, db database.Database, graphDb
return err
}

pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_added", "position": tagToPosition(tag)}).Add(float64(countNewTagged))
pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_removed", "position": tagToPosition(tag)}).Add(float64(oldTaggedNodes.Cardinality()))

slog.InfoContext(
ctx,
"AGT: Completed in memory tagging",
Expand Down
93 changes: 93 additions & 0 deletions packages/go/analysis/agt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import (
"testing"

"github.com/gofrs/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/specterops/bloodhound/cmd/api/src/database/types/null"
"github.com/specterops/bloodhound/cmd/api/src/model"
"github.com/specterops/bloodhound/cmd/api/src/model/appcfg"
Expand Down Expand Up @@ -311,6 +313,8 @@ func TestTagAssetGroupNodesForTag(t *testing.T) {
)

t.Run("removes the tag kind from every previously tagged node when no selector references them", func(t *testing.T) {
pzNodeTagCounterVec.Reset()

tag, err := bhDB.CreateAssetGroupTag(testCtx, model.AssetGroupTagTypeLabel, testActor, "regression label all", "", null.Int32{}, null.Bool{}, null.String{})
require.NoError(t, err)

Expand Down Expand Up @@ -353,9 +357,13 @@ func TestTagAssetGroupNodesForTag(t *testing.T) {
_, present := nodesToUpdate[nodeId.Uint64()]
assert.Truef(t, present, "expected node %d to be queued for kind removal", nodeId)
}

assert.Equal(t, 0.0, testutil.ToFloat64(pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_added", "position": "label"})))
assert.Equal(t, float64(nodeCount), testutil.ToFloat64(pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_removed", "position": "label"})))
})

t.Run("preserves the tag on selected nodes and removes it from no longer selected nodes", func(t *testing.T) {
pzNodeTagCounterVec.Reset()
tag, err := bhDB.CreateAssetGroupTag(testCtx, model.AssetGroupTagTypeLabel, testActor, "regression label mixed", "", null.Int32{}, null.Bool{}, null.String{})
require.NoError(t, err)

Expand Down Expand Up @@ -418,5 +426,90 @@ func TestTagAssetGroupNodesForTag(t *testing.T) {
}
_, selectedEnqueued := nodesToUpdate[selectedNodeId.Uint64()]
assert.Falsef(t, selectedEnqueued, "expected selected node %d to NOT be queued for kind removal", selectedNodeId)

assert.Equal(t, 0.0, testutil.ToFloat64(pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_added", "position": "label"})))
assert.Equal(t, float64(len(expectedRemovals)), testutil.ToFloat64(pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_removed", "position": "label"})))
})

t.Run("adds and removes node tags for a tier tag type, and verifies the position label is calculated correctly", func(t *testing.T) {
pzNodeTagCounterVec.Reset()

const (
tierPosition = 2
addedCount = 3 // selected by a selector but none with the tag exist yet in the graph
removedCount = 2 // already carrying the tag kind in the graph but no longer selected
)

tag, err := bhDB.CreateAssetGroupTag(testCtx, model.AssetGroupTagTypeTier, testActor, "regression tier", "", null.Int32From(tierPosition), null.Bool{}, null.String{})
require.NoError(t, err)

selector, err := bhDB.CreateAssetGroupTagSelector(testCtx, tag.ID, testActor, "regression tier selector", "", false, true, model.SelectorAutoCertifyMethodDisabled, []model.SelectorSeed{
{Type: model.SelectorTypeObjectId, Value: "REGRESSION-TIER-NO-MATCH"},
})
require.NoError(t, err)

tagKind := tag.ToKind()
require.NoError(t, assertGraphKinds(testCtx, graphDB, graph.Kinds{tagKind}))

// Create nodes that are selected but do not yet carry the tag kind — these drive tag_added.
var addedNodeIds []graph.ID
require.NoError(t, graphDB.WriteTransaction(testCtx, func(tx graph.Transaction) error {
for i := 0; i < addedCount; i++ {
node, err := tx.CreateNode(graph.AsProperties(graph.PropertyMap{
common.Name: fmt.Sprintf("regression-tier-added-%d", i),
common.ObjectID: fmt.Sprintf("REGRESSION-TIER-ADDED-ID-%d", i),
}), ad.Entity, ad.User)
if err != nil {
return err
}
addedNodeIds = append(addedNodeIds, node.ID)
}
return nil
}))

// add these nodes to the selector so they are "selected" for this tag and should have the tag kind added
for i, nodeId := range addedNodeIds {
require.NoError(t, bhDB.InsertSelectorNode(
testCtx,
tag.ID,
selector.ID,
nodeId,
model.AssetGroupCertificationManual,
null.StringFrom(model.AssetGroupActorBloodHound),
model.AssetGroupSelectorNodeSourceSeed,
ad.User.String(),
"",
fmt.Sprintf("REGRESSION-TIER-ADDED-ID-%d", i),
fmt.Sprintf("regression-tier-added-%d", i),
))
}

// Create nodes that already carry the tag kind but have no selector node row — these drive tag_removed.
var removedNodeIds []graph.ID
require.NoError(t, graphDB.WriteTransaction(testCtx, func(tx graph.Transaction) error {
for i := 0; i < removedCount; i++ {
node, err := tx.CreateNode(graph.AsProperties(graph.PropertyMap{
common.Name: fmt.Sprintf("regression-tier-removed-%d", i),
common.ObjectID: fmt.Sprintf("REGRESSION-TIER-REMOVED-ID-%d", i),
}), ad.Entity, ad.User, tagKind)
if err != nil {
return err
}
removedNodeIds = append(removedNodeIds, node.ID)
}
return nil
}))

var (
exclusionSet = cardinality.NewBitmap64()
nodesToUpdate = make(map[uint64]*graph.Node)
)

require.NoError(t, tagAssetGroupNodesForTag(testCtx, bhDB, graphDB, tag, exclusionSet, nodesToUpdate))

assert.Lenf(t, nodesToUpdate, addedCount+removedCount, "expected %d nodes queued for kind update", addedCount+removedCount)

assert.Equal(t, float64(addedCount), testutil.ToFloat64(pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_added", "position": "2"})))
assert.Equal(t, float64(removedCount), testutil.ToFloat64(pzNodeTagCounterVec.With(prometheus.Labels{"action": "tag_removed", "position": "2"})))
})
}
69 changes: 69 additions & 0 deletions packages/go/analysis/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright 2026 Specter Ops, Inc.
//
// Licensed under the Apache License, Version 2.0
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// SPDX-License-Identifier: Apache-2.0

package analysis

import (
"fmt"
"strconv"

"github.com/prometheus/client_golang/prometheus"
"github.com/specterops/bloodhound/cmd/api/src/model"
)

var (
// pzNodeTagCounterVec counts tag_added and tag_removed events that occur during AGT analysis.
// The "position" label is the tier position for tiers (e.g. "1", "2") and the tag type for
// labels and owned tags ("label", "owned").
pzNodeTagCounterVec = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: model.Namespace,
Subsystem: "analysis",
Name: "pzm_node_tags_total",
Help: "Total number of privilege zone tag additions and removals applied to graph nodes during analysis.",
}, []string{
"action",
"position",
})
)

// tagToPosition returns the value used for the "position" label on pzNodeTagCounterVec.
// Tiers use their numeric position (e.g. "1", "2"). Labels and owned tags are grouped
// under their type name since they have no meaningful position.
func tagToPosition(tag model.AssetGroupTag) string {
switch tag.Type {
case model.AssetGroupTagTypeTier:
if !tag.Position.Valid {
return "unknown"
}
return strconv.Itoa(int(tag.Position.Int32))
case model.AssetGroupTagTypeLabel:
return "label"
case model.AssetGroupTagTypeOwned:
return "owned"
default:
return "unknown"
}
}

// RegisterAnalysisMetrics registers all analysis-subsystem Prometheus metrics
// with the provided registerer. Additional analysis metrics should be added here.
func RegisterAnalysisMetrics(registerer prometheus.Registerer) error {
if err := registerer.Register(pzNodeTagCounterVec); err != nil {
return fmt.Errorf("failed to register analysis metrics: %w", err)
}

return nil
}
5 changes: 5 additions & 0 deletions packages/go/metricsregistration/metricsregistration.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ import (

"github.com/prometheus/client_golang/prometheus"
"github.com/specterops/bloodhound/cmd/api/src/api/middleware"
"github.com/specterops/bloodhound/packages/go/analysis"
"github.com/specterops/bloodhound/packages/go/analysis/post"
)

// RegisterBHCEMetrics registers all BHCE subsystem Prometheus metrics with the
// provided registerer.
func RegisterBHCEMetrics(registerer prometheus.Registerer) error {
if err := analysis.RegisterAnalysisMetrics(registerer); err != nil {
return fmt.Errorf("failed to register analysis metrics: %w", err)
}

if err := post.RegisterPostProcessingMetrics(registerer); err != nil {
return fmt.Errorf("failed to register post-processing metrics: %w", err)
}
Expand Down
Loading