diff --git a/go.mod b/go.mod index 0d7bad7d3e1..b45ce5196b7 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/packages/go/analysis/agt.go b/packages/go/analysis/agt.go index b7987d436c7..28d0510fd3d 100644 --- a/packages/go/analysis/agt.go +++ b/packages/go/analysis/agt.go @@ -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" @@ -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", diff --git a/packages/go/analysis/agt_test.go b/packages/go/analysis/agt_test.go index 6f651d45a46..34bb01ec414 100644 --- a/packages/go/analysis/agt_test.go +++ b/packages/go/analysis/agt_test.go @@ -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" @@ -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) @@ -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) @@ -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"}))) }) } diff --git a/packages/go/analysis/metrics.go b/packages/go/analysis/metrics.go new file mode 100644 index 00000000000..0877443d8f8 --- /dev/null +++ b/packages/go/analysis/metrics.go @@ -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 +} diff --git a/packages/go/metricsregistration/metricsregistration.go b/packages/go/metricsregistration/metricsregistration.go index 281d7c3bff6..739e68558ee 100644 --- a/packages/go/metricsregistration/metricsregistration.go +++ b/packages/go/metricsregistration/metricsregistration.go @@ -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) }