From 002b1cad80bfa605cc8f66ce13fcf738182a4803 Mon Sep 17 00:00:00 2001 From: John Hopper Date: Wed, 18 Feb 2026 08:27:47 -0800 Subject: [PATCH] feat+chore: refactor Azure UserRoleAssignments post-processing logic * More aggressive caching of bitmaps following pprof top10 guidance for hotspots * Break out components of old analysis tools into bespoke `post` package * Author better async tooling for post-processed relationships in `sink.go` * Author graph entity tracking via xxhash tooling to allow `sink.go` to filter only changes to the database * Remove UserRoleAssignment post-processed edges from legacy `DeleteTransitEdges` function * Update interfaces, add documentation * Added usage of ImmutableDuplex[T uint32 | uint64] to prevent mutation of cached bitmaps * Removed old code no longer used * Added metrics tooling to make global registration of new metrics easier * Added more trace logging and metric features --- .../src/analysis/ad/adcs_integration_test.go | 117 ++--- .../src/analysis/ad/ntlm_integration_test.go | 15 +- cmd/api/src/analysis/ad/post.go | 22 +- cmd/api/src/analysis/azure/post.go | 5 +- packages/cue/bh/azure/azure.cue | 5 - packages/go/analysis/ad/adcs.go | 55 +-- packages/go/analysis/ad/esc1.go | 5 +- packages/go/analysis/ad/esc10.go | 9 +- packages/go/analysis/ad/esc13.go | 5 +- packages/go/analysis/ad/esc3.go | 23 +- packages/go/analysis/ad/esc4.go | 5 +- packages/go/analysis/ad/esc6.go | 9 +- packages/go/analysis/ad/esc9.go | 9 +- packages/go/analysis/ad/esc_shared.go | 47 +- packages/go/analysis/ad/local_groups.go | 17 +- packages/go/analysis/ad/ntlm.go | 27 +- packages/go/analysis/ad/owns.go | 11 +- packages/go/analysis/ad/post.go | 35 +- packages/go/analysis/azure/post.go | 409 ++++++++---------- packages/go/analysis/azure/post_test.go | 4 +- packages/go/analysis/azure/role.go | 118 +++-- packages/go/analysis/azure/role_approver.go | 13 +- packages/go/analysis/azure/tenant.go | 3 + packages/go/analysis/delta/tracker.go | 277 ++++++++++++ packages/go/analysis/delta/tracker_test.go | 208 +++++++++ packages/go/analysis/hybrid/hybrid.go | 11 +- packages/go/analysis/impact/aggregator.go | 260 ----------- .../go/analysis/impact/aggregator_test.go | 138 ------ .../impact/aggregator_test_diagram.png | Bin 209989 -> 0 bytes packages/go/analysis/post.go | 63 +-- packages/go/analysis/post/job.go | 28 ++ packages/go/analysis/post/sink.go | 230 ++++++++++ packages/go/analysis/post/stats.go | 121 ++++++ packages/go/analysis/post_operation.go | 147 ++----- packages/go/bhlog/attr/attr.go | 29 +- packages/go/graphschema/azure/azure.go | 2 +- packages/go/metrics/registry.go | 165 +++++++ packages/go/trace/trace.go | 133 ++++++ .../bh-shared-ui/src/components/index.ts | 2 +- 39 files changed, 1711 insertions(+), 1071 deletions(-) create mode 100644 packages/go/analysis/delta/tracker.go create mode 100644 packages/go/analysis/delta/tracker_test.go delete mode 100644 packages/go/analysis/impact/aggregator.go delete mode 100644 packages/go/analysis/impact/aggregator_test.go delete mode 100644 packages/go/analysis/impact/aggregator_test_diagram.png create mode 100644 packages/go/analysis/post/job.go create mode 100644 packages/go/analysis/post/sink.go create mode 100644 packages/go/analysis/post/stats.go create mode 100644 packages/go/metrics/registry.go create mode 100644 packages/go/trace/trace.go diff --git a/cmd/api/src/analysis/ad/adcs_integration_test.go b/cmd/api/src/analysis/ad/adcs_integration_test.go index a484ed278c0..14bb1876b30 100644 --- a/cmd/api/src/analysis/ad/adcs_integration_test.go +++ b/cmd/api/src/analysis/ad/adcs_integration_test.go @@ -24,6 +24,7 @@ import ( "github.com/specterops/bloodhound/packages/go/analysis" ad2 "github.com/specterops/bloodhound/packages/go/analysis/ad" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema" "github.com/specterops/dawgs/ops" @@ -77,7 +78,7 @@ func TestADCSESC1(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC1(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC1.String(), err) } @@ -191,7 +192,7 @@ func TestADCSESC1(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC1(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC1.String(), err) } @@ -242,7 +243,7 @@ func TestGoldenCert(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostGoldenCert(ctx, tx, outC, innerEnterpriseCA, targetDomains); err != nil { t.Logf("failed post processing for %s: %v", ad.GoldenCert.String(), err) } @@ -499,19 +500,19 @@ func TestEnrollOnBehalfOf(t *testing.T) { require.Len(t, results, 3) - require.Contains(t, results, analysis.CreatePostRelationshipJob{ + require.Contains(t, results, post.EnsureRelationshipJob{ FromID: harness.EnrollOnBehalfOfHarness1.CertTemplate11.ID, ToID: harness.EnrollOnBehalfOfHarness1.CertTemplate12.ID, Kind: ad.EnrollOnBehalfOf, }) - require.Contains(t, results, analysis.CreatePostRelationshipJob{ + require.Contains(t, results, post.EnsureRelationshipJob{ FromID: harness.EnrollOnBehalfOfHarness1.CertTemplate13.ID, ToID: harness.EnrollOnBehalfOfHarness1.CertTemplate12.ID, Kind: ad.EnrollOnBehalfOf, }) - require.Contains(t, results, analysis.CreatePostRelationshipJob{ + require.Contains(t, results, post.EnsureRelationshipJob{ FromID: harness.EnrollOnBehalfOfHarness1.CertTemplate12.ID, ToID: harness.EnrollOnBehalfOfHarness1.CertTemplate12.ID, Kind: ad.EnrollOnBehalfOf, @@ -566,7 +567,7 @@ func TestEnrollOnBehalfOf(t *testing.T) { require.Nil(t, err) require.Len(t, results, 1) - require.Contains(t, results, analysis.CreatePostRelationshipJob{ + require.Contains(t, results, post.EnsureRelationshipJob{ FromID: harness.EnrollOnBehalfOfHarness2.CertTemplate21.ID, ToID: harness.EnrollOnBehalfOfHarness2.CertTemplate23.ID, Kind: ad.EnrollOnBehalfOf, @@ -641,7 +642,7 @@ func TestADCSESC3(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC3(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC3.String(), err) } @@ -692,7 +693,7 @@ func TestADCSESC3(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC3(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC3.String(), err) } @@ -755,7 +756,7 @@ func TestADCSESC3(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC3(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC3.String(), err) } @@ -817,7 +818,7 @@ func TestADCSESC4(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC4(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC4.String(), err) } @@ -882,7 +883,7 @@ func TestADCSESC4(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC4(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC4.String(), err) } @@ -952,7 +953,7 @@ func TestADCSESC4(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC4(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC4.String(), err) } @@ -1003,7 +1004,7 @@ func TestADCSESC4(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC4(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC4.String(), err) } @@ -1058,7 +1059,7 @@ func TestADCSESC4Composition(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC4(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC4.String(), err) } @@ -1283,7 +1284,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1335,7 +1336,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1384,7 +1385,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1434,7 +1435,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1484,7 +1485,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1532,7 +1533,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1607,7 +1608,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1657,7 +1658,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1704,7 +1705,7 @@ func TestADCSESC9a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9a.String(), err) } @@ -1758,7 +1759,7 @@ func TestADCSESC9b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9b.String(), err) } @@ -1810,7 +1811,7 @@ func TestADCSESC9b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9b.String(), err) } @@ -1858,7 +1859,7 @@ func TestADCSESC9b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9b.String(), err) } @@ -1907,7 +1908,7 @@ func TestADCSESC9b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9b.String(), err) } @@ -1956,7 +1957,7 @@ func TestADCSESC9b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9b.String(), err) } @@ -2035,7 +2036,7 @@ func TestADCSESC9b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9b.String(), err) } @@ -2085,7 +2086,7 @@ func TestADCSESC9b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC9b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC9b.String(), err) } @@ -2134,7 +2135,7 @@ func TestADCSESC6a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6a.String(), err) } @@ -2183,7 +2184,7 @@ func TestADCSESC6a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6a.String(), err) } @@ -2231,7 +2232,7 @@ func TestADCSESC6a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6a.String(), err) } @@ -2333,7 +2334,7 @@ func TestADCSESC6a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6a.String(), err) } @@ -2387,7 +2388,7 @@ func TestADCSESC6b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6b.String(), err) } @@ -2491,7 +2492,7 @@ func TestADCSESC6b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6b.String(), err) } @@ -2540,7 +2541,7 @@ func TestADCSESC6b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6b.String(), err) } @@ -2589,7 +2590,7 @@ func TestADCSESC6b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6b.String(), err) } @@ -2644,7 +2645,7 @@ func TestADCSESC6b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6b.String(), err) } @@ -2693,7 +2694,7 @@ func TestADCSESC6b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC6b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC6b.String(), err) } @@ -2742,7 +2743,7 @@ func TestADCSESC10a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10a.String(), err) } @@ -2795,7 +2796,7 @@ func TestADCSESC10a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10a.String(), err) } @@ -2845,7 +2846,7 @@ func TestADCSESC10a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10a.String(), err) } @@ -2896,7 +2897,7 @@ func TestADCSESC10a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10a.String(), err) } @@ -2976,7 +2977,7 @@ func TestADCSESC10a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10a.String(), err) } @@ -3025,7 +3026,7 @@ func TestADCSESC10a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10a.String(), err) } @@ -3074,7 +3075,7 @@ func TestADCSESC10a(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10a(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10a.String(), err) } @@ -3123,7 +3124,7 @@ func TestADCSESC13(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC13(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC13.String(), err) } else { @@ -3190,7 +3191,7 @@ func TestADCSESC13(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC13(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC13.String(), err) } else { @@ -3262,7 +3263,7 @@ func TestADCSESC13(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC13(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC13.String(), err) } else { @@ -3356,7 +3357,7 @@ func TestADCSESC10b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10b.String(), err) } @@ -3407,7 +3408,7 @@ func TestADCSESC10b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10b.String(), err) } @@ -3456,7 +3457,7 @@ func TestADCSESC10b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10b.String(), err) } @@ -3505,7 +3506,7 @@ func TestADCSESC10b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10b.String(), err) } @@ -3586,7 +3587,7 @@ func TestADCSESC10b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10b.String(), err) } @@ -3635,7 +3636,7 @@ func TestADCSESC10b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10b.String(), err) } @@ -3684,7 +3685,7 @@ func TestADCSESC10b(t *testing.T) { } } - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := ad2.PostADCSESC10b(ctx, tx, outC, localGroupData, innerEnterpriseCA, targetDomains, cache); err != nil { t.Logf("failed post processing for %s: %v", ad.ADCSESC10b.String(), err) } diff --git a/cmd/api/src/analysis/ad/ntlm_integration_test.go b/cmd/api/src/analysis/ad/ntlm_integration_test.go index 3b72e7a9e3d..544ec1eee00 100644 --- a/cmd/api/src/analysis/ad/ntlm_integration_test.go +++ b/cmd/api/src/analysis/ad/ntlm_integration_test.go @@ -27,6 +27,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/test/integration" "github.com/specterops/bloodhound/packages/go/analysis" ad2 "github.com/specterops/bloodhound/packages/go/analysis/ad" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/bloodhound/packages/go/graphschema/common" @@ -156,7 +157,7 @@ func TestPostNTLMRelaySMB(t *testing.T) { ntlmCache, err := ad2.NewNTLMCache(context.Background(), db, grouplocalGroupData) require.NoError(t, err) - err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, computer := range computers { innerComputer := computer domainSid, _ := innerComputer.Properties.Get(ad.DomainSID.String()).String() @@ -232,7 +233,7 @@ func TestPostNTLMRelaySMB(t *testing.T) { ntlmCache, err := ad2.NewNTLMCache(context.Background(), db, grouplocalGroupData) require.NoError(t, err) - err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, computer := range computers { innerComputer := computer @@ -284,7 +285,7 @@ func TestNTLMRelayToSMBComposition(t *testing.T) { ntlmCache, err := ad2.NewNTLMCache(context.Background(), db, grouplocalGroupData) require.NoError(t, err) - err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, computer := range computers { innerComputer := computer domainSid, _ := innerComputer.Properties.Get(ad.DomainSID.String()).String() @@ -360,7 +361,7 @@ func TestPostCoerceAndRelayNTLMToLDAP(t *testing.T) { protectedUsersCache, err := ad2.FetchProtectedUsersMappedToDomains(testContext.Context(), db, grouplocalGroupData) require.NoError(t, err) - err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, computer := range computers { innerComputer := computer domainSid, err := innerComputer.Properties.Get(ad.DomainSID.String()).String() @@ -440,7 +441,7 @@ func TestPostCoerceAndRelayNTLMToLDAP(t *testing.T) { protectedUsersCache, err := ad2.FetchProtectedUsersMappedToDomains(testContext.Context(), db, grouplocalGroupData) require.NoError(t, err) - err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, computer := range computers { innerComputer := computer domainSid, err := innerComputer.Properties.Get(ad.DomainSID.String()).String() @@ -516,7 +517,7 @@ func TestPostCoerceAndRelayNTLMToLDAP(t *testing.T) { protectedUsersCache, err := ad2.FetchProtectedUsersMappedToDomains(testContext.Context(), db, grouplocalGroupData) require.NoError(t, err) - err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, computer := range computers { innerComputer := computer domainSid, err := innerComputer.Properties.Get(ad.DomainSID.String()).String() @@ -571,7 +572,7 @@ func TestPostCoerceAndRelayNTLMToLDAP(t *testing.T) { protectedUsersCache, err := ad2.FetchProtectedUsersMappedToDomains(testContext.Context(), db, grouplocalGroupData) require.NoError(t, err) - err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, computer := range computers { innerComputer := computer domainSid, err := innerComputer.Properties.Get(ad.DomainSID.String()).String() diff --git a/cmd/api/src/analysis/ad/post.go b/cmd/api/src/analysis/ad/post.go index c6b7a6773fb..76b6839b041 100644 --- a/cmd/api/src/analysis/ad/post.go +++ b/cmd/api/src/analysis/ad/post.go @@ -1,4 +1,4 @@ -// Copyright 2023 Specter Ops, Inc. +// 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. @@ -13,6 +13,21 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 +// Copyright 2post.CreatePostRelationshipJob23 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.post.CreatePostRelationshipJob +// 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.post.CreatePostRelationshipJob +// +// 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.post.CreatePostRelationshipJob package ad @@ -22,6 +37,7 @@ import ( "github.com/specterops/bloodhound/packages/go/analysis" adAnalysis "github.com/specterops/bloodhound/packages/go/analysis/ad" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -29,7 +45,7 @@ import ( "github.com/specterops/dawgs/graph" ) -func Post(ctx context.Context, db graph.Database, adcsEnabled, citrixEnabled, ntlmEnabled bool, compositionCounter *analysis.CompositionCounter) (*analysis.AtomicPostProcessingStats, error) { +func Post(ctx context.Context, db graph.Database, adcsEnabled, citrixEnabled, ntlmEnabled bool, compositionCounter *analysis.CompositionCounter) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -39,7 +55,7 @@ func Post(ctx context.Context, db graph.Database, adcsEnabled, citrixEnabled, nt attr.Scope("step"), )() - aggregateStats := analysis.NewAtomicPostProcessingStats() + aggregateStats := post.NewAtomicPostProcessingStats() if err := adAnalysis.FixWellKnownNodeTypes(ctx, db); err != nil { return &aggregateStats, err diff --git a/cmd/api/src/analysis/azure/post.go b/cmd/api/src/analysis/azure/post.go index 3c1b2ccfafb..4264c718a23 100644 --- a/cmd/api/src/analysis/azure/post.go +++ b/cmd/api/src/analysis/azure/post.go @@ -23,6 +23,7 @@ import ( "github.com/specterops/bloodhound/packages/go/analysis" azureAnalysis "github.com/specterops/bloodhound/packages/go/analysis/azure" "github.com/specterops/bloodhound/packages/go/analysis/hybrid" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -30,7 +31,7 @@ import ( "github.com/specterops/dawgs/graph" ) -func Post(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { +func Post(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -40,7 +41,7 @@ func Post(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessin attr.Scope("step"), )() - aggregateStats := analysis.NewAtomicPostProcessingStats() + aggregateStats := post.NewAtomicPostProcessingStats() if err := azureAnalysis.FixManagementGroupNames(ctx, db); err != nil { slog.WarnContext(ctx, "Error fixing management group names", attr.Error(err)) } diff --git a/packages/cue/bh/azure/azure.cue b/packages/cue/bh/azure/azure.cue index efe534b52cd..5a910ff86e0 100644 --- a/packages/cue/bh/azure/azure.cue +++ b/packages/cue/bh/azure/azure.cue @@ -952,11 +952,6 @@ PathfindingRelationships: list.Concat([InboundOutboundRelationshipKinds]) PostProcessedRelationships: [ AddSecret, ExecuteCommand, - ResetPassword, - AddMembers, - GlobalAdmin, - PrivilegedRoleAdmin, - PrivilegedAuthAdmin, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, diff --git a/packages/go/analysis/ad/adcs.go b/packages/go/analysis/ad/adcs.go index 2f5c4987ecf..284da4bb1e1 100644 --- a/packages/go/analysis/ad/adcs.go +++ b/packages/go/analysis/ad/adcs.go @@ -23,6 +23,7 @@ import ( "log/slog" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -35,7 +36,7 @@ var ( EkuCertRequestAgent = "1.3.6.1.4.1.311.20.2.1" ) -func PostADCS(ctx context.Context, db graph.Database, localGroupData *LocalGroupData, adcsEnabled bool) (*analysis.AtomicPostProcessingStats, ADCSCache, error) { +func PostADCS(ctx context.Context, db graph.Database, localGroupData *LocalGroupData, adcsEnabled bool) (*post.AtomicPostProcessingStats, ADCSCache, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -47,19 +48,19 @@ func PostADCS(ctx context.Context, db graph.Database, localGroupData *LocalGroup var cache = NewADCSCache() if enterpriseCertAuthorities, err := FetchNodesByKind(ctx, db, ad.EnterpriseCA); err != nil { - return &analysis.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching enterpriseCA nodes: %w", err) + return &post.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching enterpriseCA nodes: %w", err) } else if rootCertAuthorities, err := FetchNodesByKind(ctx, db, ad.RootCA); err != nil { - return &analysis.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching rootCA nodes: %w", err) + return &post.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching rootCA nodes: %w", err) } else if aiaCertAuthorities, err := FetchNodesByKind(ctx, db, ad.AIACA); err != nil { - return &analysis.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching AIACA nodes: %w", err) + return &post.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching AIACA nodes: %w", err) } else if certTemplates, err := FetchNodesByKind(ctx, db, ad.CertTemplate); err != nil { - return &analysis.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching cert template nodes: %w", err) + return &post.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed fetching cert template nodes: %w", err) } else if step1Stats, err := postADCSPreProcessStep1(ctx, db, enterpriseCertAuthorities, rootCertAuthorities, aiaCertAuthorities, certTemplates); err != nil { - return &analysis.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed adcs pre-processing step 1: %w", err) + return &post.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed adcs pre-processing step 1: %w", err) } else if err := cache.BuildCache(ctx, db, enterpriseCertAuthorities, certTemplates); err != nil { - return &analysis.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed building ADCS cache: %w", err) + return &post.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed building ADCS cache: %w", err) } else if step2Stats, err := postADCSPreProcessStep2(ctx, db, cache); err != nil { - return &analysis.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed adcs pre-processing step 2: %w", err) + return &post.AtomicPostProcessingStats{}, cache, fmt.Errorf("failed adcs pre-processing step 2: %w", err) } else { operation := analysis.NewPostRelationshipOperation(ctx, db, "ADCS Post Processing") @@ -84,41 +85,41 @@ func PostADCS(ctx context.Context, db graph.Database, localGroupData *LocalGroup } // postADCSPreProcessStep1 processes the edges that are not dependent on any other post-processed edges -func postADCSPreProcessStep1(ctx context.Context, db graph.Database, enterpriseCertAuthorities, rootCertAuthorities, aiaCertAuthorities, certTemplates []*graph.Node) (*analysis.AtomicPostProcessingStats, error) { +func postADCSPreProcessStep1(ctx context.Context, db graph.Database, enterpriseCertAuthorities, rootCertAuthorities, aiaCertAuthorities, certTemplates []*graph.Node) (*post.AtomicPostProcessingStats, error) { operation := analysis.NewPostRelationshipOperation(ctx, db, "ADCS Post Processing Step 1") // TODO clean up the operation.Done() calls below if err := PostTrustedForNTAuth(ctx, db, operation); err != nil { operation.Done() - return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.TrustedForNTAuth.String(), err) + return &post.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.TrustedForNTAuth.String(), err) } else if err := PostIssuedSignedBy(operation, enterpriseCertAuthorities, rootCertAuthorities, aiaCertAuthorities); err != nil { operation.Done() - return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.IssuedSignedBy.String(), err) + return &post.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.IssuedSignedBy.String(), err) } else if err := PostEnterpriseCAFor(operation, enterpriseCertAuthorities); err != nil { operation.Done() - return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.EnterpriseCAFor.String(), err) + return &post.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.EnterpriseCAFor.String(), err) } else if err = PostExtendedByPolicyBinding(operation, certTemplates); err != nil { operation.Done() - return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.ExtendedByPolicy.String(), err) + return &post.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.ExtendedByPolicy.String(), err) } else { return &operation.Stats, operation.Done() } } // postADCSPreProcessStep2 Processes the edges that are dependent on those processed in postADCSPreProcessStep1 -func postADCSPreProcessStep2(ctx context.Context, db graph.Database, cache ADCSCache) (*analysis.AtomicPostProcessingStats, error) { +func postADCSPreProcessStep2(ctx context.Context, db graph.Database, cache ADCSCache) (*post.AtomicPostProcessingStats, error) { operation := analysis.NewPostRelationshipOperation(ctx, db, "ADCS Post Processing Step 2") if err := PostEnrollOnBehalfOf(cache, operation); err != nil { operation.Done() - return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.EnrollOnBehalfOf.String(), err) + return &post.AtomicPostProcessingStats{}, fmt.Errorf("failed post processing for %s: %w", ad.EnrollOnBehalfOf.String(), err) } else { return &operation.Stats, operation.Done() } } -func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, targetDomains *graph.NodeSet, localGroupData *LocalGroupData, cache ADCSCache, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob]) { - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { +func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, targetDomains *graph.NodeSet, localGroupData *LocalGroupData, cache ADCSCache, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob]) { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostGoldenCert(ctx, tx, outC, enterpriseCA, targetDomains); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.GoldenCert.String(), err)) } else if err != nil { @@ -127,7 +128,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC1(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC1.String(), err)) } else if err != nil { @@ -136,7 +137,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC3(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC3.String(), err)) } else if err != nil { @@ -145,7 +146,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC4(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC4.String(), err)) } else if err != nil { @@ -154,7 +155,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC6a(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC6a.String(), err)) } else if err != nil { @@ -163,7 +164,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC6b(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC6b.String(), err)) } else if err != nil { @@ -172,7 +173,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC9a(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC9a.String(), err)) } else if err != nil { @@ -181,7 +182,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC9b(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC9b.String(), err)) } else if err != nil { @@ -190,7 +191,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC10a(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC10a.String(), err)) } else if err != nil { @@ -199,7 +200,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC10b(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC10b.String(), err)) } else if err != nil { @@ -208,7 +209,7 @@ func processEnterpriseCAWithValidCertChainToDomain(enterpriseCA *graph.Node, tar return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if err := PostADCSESC13(ctx, tx, outC, localGroupData, enterpriseCA, targetDomains, cache); errors.Is(err, graph.ErrPropertyNotFound) { slog.WarnContext(ctx, fmt.Sprintf("Post processing for %s: %v", ad.ADCSESC13.String(), err)) } else if err != nil { diff --git a/packages/go/analysis/ad/esc1.go b/packages/go/analysis/ad/esc1.go index f1df6a23c82..c11ba0de13e 100644 --- a/packages/go/analysis/ad/esc1.go +++ b/packages/go/analysis/ad/esc1.go @@ -23,6 +23,7 @@ import ( "sync" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/cardinality" "github.com/specterops/dawgs/graph" @@ -32,7 +33,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostADCSESC1(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC1(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { results := cardinality.NewBitmap64() if publishedCertTemplates := cache.GetPublishedTemplateCache(enterpriseCA.ID); len(publishedCertTemplates) == 0 { return nil @@ -52,7 +53,7 @@ func PostADCSESC1(ctx context.Context, tx graph.Transaction, outC chan<- analysi results.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC1, diff --git a/packages/go/analysis/ad/esc10.go b/packages/go/analysis/ad/esc10.go index 858d4d94a5f..9ea03b3ab5e 100644 --- a/packages/go/analysis/ad/esc10.go +++ b/packages/go/analysis/ad/esc10.go @@ -23,6 +23,7 @@ import ( "sync" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/ein" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/cardinality" @@ -33,7 +34,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostADCSESC10a(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC10a(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { if publishedCertTemplates := cache.GetPublishedTemplateCache(eca.ID); len(publishedCertTemplates) == 0 { return nil } else if ecaEnrollers := cache.GetEnterpriseCAEnrollers(eca.ID); len(ecaEnrollers) == 0 { @@ -68,7 +69,7 @@ func PostADCSESC10a(ctx context.Context, tx graph.Transaction, outC chan<- analy results.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { if cache.HasUPNCertMappingInForest(domain.ID.Uint64()) { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC10a, @@ -81,7 +82,7 @@ func PostADCSESC10a(ctx context.Context, tx graph.Transaction, outC chan<- analy return nil } -func PostADCSESC10b(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC10b(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { if publishedCertTemplates := cache.GetPublishedTemplateCache(enterpriseCA.ID); len(publishedCertTemplates) == 0 { return nil } else if ecaEnrollers := cache.GetEnterpriseCAEnrollers(enterpriseCA.ID); len(ecaEnrollers) == 0 { @@ -113,7 +114,7 @@ func PostADCSESC10b(ctx context.Context, tx graph.Transaction, outC chan<- analy results.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { if cache.HasUPNCertMappingInForest(domain.ID.Uint64()) { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC10b, diff --git a/packages/go/analysis/ad/esc13.go b/packages/go/analysis/ad/esc13.go index 3d70f510dbb..ba77c8c6c41 100644 --- a/packages/go/analysis/ad/esc13.go +++ b/packages/go/analysis/ad/esc13.go @@ -24,6 +24,7 @@ import ( "sync" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/cardinality" "github.com/specterops/dawgs/graph" @@ -33,7 +34,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostADCSESC13(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC13(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { if publishedCertTemplates := cache.GetPublishedTemplateCache(eca.ID); len(publishedCertTemplates) == 0 { return nil } else { @@ -59,7 +60,7 @@ func PostADCSESC13(ctx context.Context, tx graph.Transaction, outC chan<- analys for _, domain := range targetDomains.Slice() { if groupIsContainedOrTrusted(tx, group, domain) { filtered.Each(func(value uint64) bool { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: group.ID, Kind: ad.ADCSESC13, diff --git a/packages/go/analysis/ad/esc3.go b/packages/go/analysis/ad/esc3.go index 182671e75a7..dcce66fa948 100644 --- a/packages/go/analysis/ad/esc3.go +++ b/packages/go/analysis/ad/esc3.go @@ -25,6 +25,7 @@ import ( "sync" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/cardinality" @@ -35,7 +36,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostADCSESC3(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, eca2 *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC3(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, eca2 *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { results := cardinality.NewBitmap64() if publishedCertTemplates := cache.GetPublishedTemplateCache(eca2.ID); len(publishedCertTemplates) == 0 { return nil @@ -127,7 +128,7 @@ func PostADCSESC3(ctx context.Context, tx graph.Transaction, outC chan<- analysi results.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC3, @@ -139,7 +140,7 @@ func PostADCSESC3(ctx context.Context, tx graph.Transaction, outC chan<- analysi return nil } -func PostEnrollOnBehalfOf(cache ADCSCache, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob]) error { +func PostEnrollOnBehalfOf(cache ADCSCache, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob]) error { versionOneTemplates := make([]*graph.Node, 0) versionTwoTemplates := make([]*graph.Node, 0) for _, node := range cache.GetCertTemplates() { @@ -166,7 +167,7 @@ func PostEnrollOnBehalfOf(cache ADCSCache, operation analysis.StatTrackedOperati if publishedCertTemplates := cache.GetPublishedTemplateCache(enterpriseCA.ID); len(publishedCertTemplates) == 0 { return nil } else { - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if results, err := EnrollOnBehalfOfVersionTwo(tx, versionTwoTemplates, publishedCertTemplates, innerDomain); err != nil { return err } else { @@ -180,7 +181,7 @@ func PostEnrollOnBehalfOf(cache ADCSCache, operation analysis.StatTrackedOperati } }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if results, err := EnrollOnBehalfOfVersionOne(tx, versionOneTemplates, publishedCertTemplates, innerDomain); err != nil { return err } else { @@ -201,8 +202,8 @@ func PostEnrollOnBehalfOf(cache ADCSCache, operation analysis.StatTrackedOperati return nil } -func EnrollOnBehalfOfVersionTwo(tx graph.Transaction, versionTwoCertTemplates, publishedTemplates []*graph.Node, domainNode *graph.Node) ([]analysis.CreatePostRelationshipJob, error) { - results := make([]analysis.CreatePostRelationshipJob, 0) +func EnrollOnBehalfOfVersionTwo(tx graph.Transaction, versionTwoCertTemplates, publishedTemplates []*graph.Node, domainNode *graph.Node) ([]post.EnsureRelationshipJob, error) { + results := make([]post.EnsureRelationshipJob, 0) for _, certTemplateOne := range publishedTemplates { if hasBadEku, err := certTemplateHasEku(certTemplateOne, EkuAnyPurpose); errors.Is(err, graph.ErrPropertyNotFound) { slog.Warn(fmt.Sprintf("Did not get EffectiveEKUs for cert template %d: %v", certTemplateOne.ID, err)) @@ -233,7 +234,7 @@ func EnrollOnBehalfOfVersionTwo(tx graph.Transaction, versionTwoCertTemplates, p } else if !isLinked { continue } else { - results = append(results, analysis.CreatePostRelationshipJob{ + results = append(results, post.EnsureRelationshipJob{ FromID: certTemplateOne.ID, ToID: certTemplateTwo.ID, Kind: ad.EnrollOnBehalfOf, @@ -262,8 +263,8 @@ func certTemplateHasEku(certTemplate *graph.Node, targetEkus ...string) (bool, e } } -func EnrollOnBehalfOfVersionOne(tx graph.Transaction, versionOneCertTemplates []*graph.Node, publishedTemplates []*graph.Node, domainNode *graph.Node) ([]analysis.CreatePostRelationshipJob, error) { - results := make([]analysis.CreatePostRelationshipJob, 0) +func EnrollOnBehalfOfVersionOne(tx graph.Transaction, versionOneCertTemplates []*graph.Node, publishedTemplates []*graph.Node, domainNode *graph.Node) ([]post.EnsureRelationshipJob, error) { + results := make([]post.EnsureRelationshipJob, 0) for _, certTemplateOne := range publishedTemplates { //prefilter as much as we can first @@ -280,7 +281,7 @@ func EnrollOnBehalfOfVersionOne(tx graph.Transaction, versionOneCertTemplates [] } else if !hasPath { continue } else { - results = append(results, analysis.CreatePostRelationshipJob{ + results = append(results, post.EnsureRelationshipJob{ FromID: certTemplateOne.ID, ToID: certTemplateTwo.ID, Kind: ad.EnrollOnBehalfOf, diff --git a/packages/go/analysis/ad/esc4.go b/packages/go/analysis/ad/esc4.go index 2ddd03b9b54..cd0e697668f 100644 --- a/packages/go/analysis/ad/esc4.go +++ b/packages/go/analysis/ad/esc4.go @@ -23,6 +23,7 @@ import ( "sync" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/cardinality" "github.com/specterops/dawgs/graph" @@ -32,7 +33,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostADCSESC4(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC4(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { // 1. principals := cardinality.NewBitmap64() publishedTemplates := cache.GetPublishedTemplateCache(enterpriseCA.ID) @@ -121,7 +122,7 @@ func PostADCSESC4(ctx context.Context, tx graph.Transaction, outC chan<- analysi principals.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC4, diff --git a/packages/go/analysis/ad/esc6.go b/packages/go/analysis/ad/esc6.go index 9198bec5f3e..416b80d6bd3 100644 --- a/packages/go/analysis/ad/esc6.go +++ b/packages/go/analysis/ad/esc6.go @@ -22,6 +22,7 @@ import ( "log/slog" "sync" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/ein" "github.com/specterops/bloodhound/packages/go/analysis" @@ -34,7 +35,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostADCSESC6a(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC6a(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { if isUserSpecifiesSanEnabledCollected, err := enterpriseCA.Properties.Get(ad.IsUserSpecifiesSanEnabledCollected.String()).Bool(); err != nil { return err } else if !isUserSpecifiesSanEnabledCollected { @@ -64,7 +65,7 @@ func PostADCSESC6a(ctx context.Context, tx graph.Transaction, outC chan<- analys } else { filteredEnrollers.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC6a, @@ -79,7 +80,7 @@ func PostADCSESC6a(ctx context.Context, tx graph.Transaction, outC chan<- analys return nil } -func PostADCSESC6b(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC6b(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, enterpriseCA *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { if isUserSpecifiesSanEnabledCollected, err := enterpriseCA.Properties.Get(ad.IsUserSpecifiesSanEnabledCollected.String()).Bool(); err != nil { return err } else if !isUserSpecifiesSanEnabledCollected { @@ -110,7 +111,7 @@ func PostADCSESC6b(ctx context.Context, tx graph.Transaction, outC chan<- analys filteredEnrollers.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { if cache.HasUPNCertMappingInForest(domain.ID.Uint64()) { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC6b, diff --git a/packages/go/analysis/ad/esc9.go b/packages/go/analysis/ad/esc9.go index 91f2bdf90ff..b2f03762787 100644 --- a/packages/go/analysis/ad/esc9.go +++ b/packages/go/analysis/ad/esc9.go @@ -23,6 +23,7 @@ import ( "sync" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/cardinality" "github.com/specterops/dawgs/graph" @@ -32,7 +33,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostADCSESC9a(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC9a(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { results := cardinality.NewBitmap64() if publishedCertTemplates := cache.GetPublishedTemplateCache(eca.ID); len(publishedCertTemplates) == 0 { @@ -67,7 +68,7 @@ func PostADCSESC9a(ctx context.Context, tx graph.Transaction, outC chan<- analys results.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { if cache.HasWeakCertBindingInForest(domain.ID.Uint64()) { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC9a, @@ -81,7 +82,7 @@ func PostADCSESC9a(ctx context.Context, tx graph.Transaction, outC chan<- analys } } -func PostADCSESC9b(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { +func PostADCSESC9b(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, localGroupData *LocalGroupData, eca *graph.Node, targetDomains *graph.NodeSet, cache ADCSCache) error { results := cardinality.NewBitmap64() if publishedCertTemplates := cache.GetPublishedTemplateCache(eca.ID); len(publishedCertTemplates) == 0 { @@ -113,7 +114,7 @@ func PostADCSESC9b(ctx context.Context, tx graph.Transaction, outC chan<- analys results.Each(func(value uint64) bool { for _, domain := range targetDomains.Slice() { if cache.HasWeakCertBindingInForest(domain.ID.Uint64()) { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: domain.ID, Kind: ad.ADCSESC9b, diff --git a/packages/go/analysis/ad/esc_shared.go b/packages/go/analysis/ad/esc_shared.go index 7df941965db..92ef4559cc8 100644 --- a/packages/go/analysis/ad/esc_shared.go +++ b/packages/go/analysis/ad/esc_shared.go @@ -25,6 +25,7 @@ import ( "strings" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/bloodhound/packages/go/slicesext" "github.com/specterops/dawgs/cardinality" @@ -34,14 +35,14 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostTrustedForNTAuth(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob]) error { +func PostTrustedForNTAuth(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob]) error { if ntAuthStoreNodes, err := FetchNodesByKind(ctx, db, ad.NTAuthStore); err != nil { return err } else { for _, node := range ntAuthStoreNodes { innerNode := node - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if thumbprints, err := innerNode.Properties.Get(ad.CertThumbprints.String()).StringSlice(); err != nil { if strings.Contains(err.Error(), graph.ErrPropertyNotFound.Error()) { slog.WarnContext(ctx, fmt.Sprintf("Unable to post-process TrustedForNTAuth edge for NTAuthStore node %d due to missing adcs data: %v", innerNode.ID, err)) @@ -55,7 +56,7 @@ func PostTrustedForNTAuth(ctx context.Context, db graph.Database, operation anal return err } else { for _, sourceNodeID := range sourceNodeIDs { - if !channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: sourceNodeID, ToID: innerNode.ID, Kind: ad.TrustedForNTAuth, @@ -75,8 +76,8 @@ func PostTrustedForNTAuth(ctx context.Context, db graph.Database, operation anal return nil } -func PostIssuedSignedBy(operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], enterpriseCertAuthorities []*graph.Node, rootCertAuthorities []*graph.Node, aiaCertAuthorities []*graph.Node) error { - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { +func PostIssuedSignedBy(operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], enterpriseCertAuthorities []*graph.Node, rootCertAuthorities []*graph.Node, aiaCertAuthorities []*graph.Node) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, node := range enterpriseCertAuthorities { if postRels, err := processCertChainParent(node, tx); err != nil && !errors.Is(err, ErrNoCertParent) { return err @@ -94,7 +95,7 @@ func PostIssuedSignedBy(operation analysis.StatTrackedOperation[analysis.CreateP return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, node := range rootCertAuthorities { if postRels, err := processCertChainParent(node, tx); err != nil && !errors.Is(err, ErrNoCertParent) { return err @@ -112,7 +113,7 @@ func PostIssuedSignedBy(operation analysis.StatTrackedOperation[analysis.CreateP return nil }) - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, node := range aiaCertAuthorities { if postRels, err := processCertChainParent(node, tx); err != nil && !errors.Is(err, ErrNoCertParent) { return err @@ -133,8 +134,8 @@ func PostIssuedSignedBy(operation analysis.StatTrackedOperation[analysis.CreateP return nil } -func PostEnterpriseCAFor(operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], enterpriseCertAuthorities []*graph.Node) error { - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { +func PostEnterpriseCAFor(operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], enterpriseCertAuthorities []*graph.Node) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, ecaNode := range enterpriseCertAuthorities { if thumbprint, err := ecaNode.Properties.Get(ad.CertThumbprint.String()).String(); err != nil { if graph.IsErrPropertyNotFound(err) { @@ -146,7 +147,7 @@ func PostEnterpriseCAFor(operation analysis.StatTrackedOperation[analysis.Create return err } else { for _, rootCANodeID := range rootCAIDs { - if !channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: ecaNode.ID, ToID: rootCANodeID, Kind: ad.EnterpriseCAFor, @@ -159,7 +160,7 @@ func PostEnterpriseCAFor(operation analysis.StatTrackedOperation[analysis.Create return err } else { for _, aiaCANodeID := range aiaCAIDs { - if !channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: ecaNode.ID, ToID: aiaCANodeID, Kind: ad.EnterpriseCAFor, @@ -175,13 +176,13 @@ func PostEnterpriseCAFor(operation analysis.StatTrackedOperation[analysis.Create return nil } -func PostGoldenCert(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, enterpriseCA *graph.Node, targetDomains *graph.NodeSet) error { +func PostGoldenCert(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, enterpriseCA *graph.Node, targetDomains *graph.NodeSet) error { if hostCAServiceComputers, err := FetchHostsCAServiceComputers(tx, enterpriseCA); err != nil { slog.ErrorContext(ctx, fmt.Sprintf("Error fetching host ca computer for enterprise ca %d: %v", enterpriseCA.ID, err)) } else { for _, computer := range hostCAServiceComputers { for _, domain := range targetDomains.Slice() { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: computer.ID, ToID: domain.ID, Kind: ad.GoldenCert, @@ -192,8 +193,8 @@ func PostGoldenCert(ctx context.Context, tx graph.Transaction, outC chan<- analy return nil } -func PostExtendedByPolicyBinding(operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], certTemplates []*graph.Node) error { - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { +func PostExtendedByPolicyBinding(operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], certTemplates []*graph.Node) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if allIssuancePolicies, err := fetchAllIssuancePolicies(tx); err != nil { return err } else { @@ -215,7 +216,7 @@ func PostExtendedByPolicyBinding(operation analysis.StatTrackedOperation[analysi continue } else if certTemplateDomain != "" && certTemplateDomain == issuancePolicyDomain { // Create ExtendedByPolicy edge - if !channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: certTemplate.ID, ToID: issuancePolicy.ID, Kind: ad.ExtendedByPolicy, @@ -263,19 +264,19 @@ func getIssuancePolicyCertOIDMap(issuancePolicies graph.NodeSet) map[string][]gr return oidMap } -func processCertChainParent(node *graph.Node, tx graph.Transaction) ([]analysis.CreatePostRelationshipJob, error) { +func processCertChainParent(node *graph.Node, tx graph.Transaction) ([]post.EnsureRelationshipJob, error) { if certChain, err := node.Properties.Get(ad.CertChain.String()).StringSlice(); err != nil { if errors.Is(err, graph.ErrPropertyNotFound) { - return []analysis.CreatePostRelationshipJob{}, nil + return []post.EnsureRelationshipJob{}, nil } - return []analysis.CreatePostRelationshipJob{}, err + return []post.EnsureRelationshipJob{}, err } else if len(certChain) > 1 { parentCert := certChain[1] if targetNodes, err := findNodesByCertThumbprint(parentCert, tx, ad.EnterpriseCA, ad.RootCA, ad.AIACA); err != nil { - return []analysis.CreatePostRelationshipJob{}, err + return []post.EnsureRelationshipJob{}, err } else { - return slicesext.Map(targetNodes, func(nodeId graph.ID) analysis.CreatePostRelationshipJob { - return analysis.CreatePostRelationshipJob{ + return slicesext.Map(targetNodes, func(nodeId graph.ID) post.EnsureRelationshipJob { + return post.EnsureRelationshipJob{ FromID: node.ID, ToID: nodeId, Kind: ad.IssuedSignedBy, @@ -283,7 +284,7 @@ func processCertChainParent(node *graph.Node, tx graph.Transaction) ([]analysis. }), nil } } else { - return []analysis.CreatePostRelationshipJob{}, ErrNoCertParent + return []post.EnsureRelationshipJob{}, ErrNoCertParent } } diff --git a/packages/go/analysis/ad/local_groups.go b/packages/go/analysis/ad/local_groups.go index ad4dbaba258..0df3b6e726f 100644 --- a/packages/go/analysis/ad/local_groups.go +++ b/packages/go/analysis/ad/local_groups.go @@ -23,6 +23,7 @@ import ( "sync/atomic" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -31,16 +32,16 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostCanRDP(parentCtx context.Context, graphDB graph.Database, localGroupData *LocalGroupData, enforceURA bool, citrixEnabled bool) (*analysis.AtomicPostProcessingStats, error) { +func PostCanRDP(parentCtx context.Context, graphDB graph.Database, localGroupData *LocalGroupData, enforceURA bool, citrixEnabled bool) (*post.AtomicPostProcessingStats, error) { var ( ctx, done = context.WithCancel(parentCtx) - stats = analysis.NewAtomicPostProcessingStats() + stats = post.NewAtomicPostProcessingStats() numComputersProcessed = &atomic.Uint64{} workC = make(chan uint64) workerWG sync.WaitGroup computerC = make(chan *CanRDPComputerData) computerWG sync.WaitGroup - postC = make(chan analysis.CreatePostRelationshipJob, 4096) + postC = make(chan post.EnsureRelationshipJob, 4096) postWG sync.WaitGroup submitStatusf = util.SLogSampleRepeated("PostCanRDP") @@ -118,7 +119,7 @@ func PostCanRDP(parentCtx context.Context, graphDB graph.Database, localGroupDat done() } else { rdpEntities.Each(func(fromID uint64) bool { - return channels.Submit(ctx, postC, analysis.CreatePostRelationshipJob{ + return channels.Submit(ctx, postC, post.EnsureRelationshipJob{ FromID: graph.ID(fromID), ToID: nextComputerRDPJob.Computer, Kind: ad.CanRDP, @@ -190,7 +191,7 @@ func PostCanRDP(parentCtx context.Context, graphDB graph.Database, localGroupDat return &stats, nil } -func PostLocalGroups(parentCtx context.Context, graphDB graph.Database, localGroupData *LocalGroupData) (*analysis.AtomicPostProcessingStats, error) { +func PostLocalGroups(parentCtx context.Context, graphDB graph.Database, localGroupData *LocalGroupData) (*post.AtomicPostProcessingStats, error) { const ( adminGroupSuffix = "-544" psRemoteGroupSuffix = "-580" @@ -205,10 +206,10 @@ func PostLocalGroups(parentCtx context.Context, graphDB graph.Database, localGro var ( ctx, done = context.WithCancel(parentCtx) - stats = analysis.NewAtomicPostProcessingStats() + stats = post.NewAtomicPostProcessingStats() computerC = make(chan uint64) reachC = make(chan reachJob, 4096) - postC = make(chan analysis.CreatePostRelationshipJob, 4096) + postC = make(chan post.EnsureRelationshipJob, 4096) numGroupsProcessed = &atomic.Uint64{} numComputersProcessed = &atomic.Uint64{} submitStatusf = util.SLogSampleRepeated("PostLocalGroups") @@ -288,7 +289,7 @@ func PostLocalGroups(parentCtx context.Context, graphDB graph.Database, localGro } localGroupData.LocalGroupMembershipDigraph.EachAdjacentNode(nextJob.targetGroup, graph.DirectionInbound, func(fromID uint64) bool { - return channels.Submit(ctx, postC, analysis.CreatePostRelationshipJob{ + return channels.Submit(ctx, postC, post.EnsureRelationshipJob{ FromID: graph.ID(fromID), ToID: graph.ID(nextJob.targetComputer), Kind: edgeKind, diff --git a/packages/go/analysis/ad/ntlm.go b/packages/go/analysis/ad/ntlm.go index ceadcc4b399..6ae0171a93f 100644 --- a/packages/go/analysis/ad/ntlm.go +++ b/packages/go/analysis/ad/ntlm.go @@ -28,6 +28,7 @@ import ( "github.com/specterops/bloodhound/packages/go/analysis" "github.com/specterops/bloodhound/packages/go/analysis/ad/wellknown" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -125,7 +126,7 @@ func NewNTLMCache(ctx context.Context, db graph.Database, localGroupData *LocalG } // PostNTLM is the initial function used to execute our NTLM analysis -func PostNTLM(ctx context.Context, db graph.Database, localGroupData *LocalGroupData, adcsCache ADCSCache, ntlmEnabled bool, compositionCounter *analysis.CompositionCounter) (*analysis.AtomicPostProcessingStats, error) { +func PostNTLM(ctx context.Context, db graph.Database, localGroupData *LocalGroupData, adcsCache ADCSCache, ntlmEnabled bool, compositionCounter *analysis.CompositionCounter) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -185,7 +186,7 @@ func PostNTLM(ctx context.Context, db graph.Database, localGroupData *LocalGroup } else if authenticatedUserGroupID, ok := ntlmCache.GetAuthenticatedUserGroupForDomain(domainSid); !ok { continue } else { - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { return PostCoerceAndRelayNTLMToSMB(tx, outC, ntlmCache, innerComputer, authenticatedUserGroupID) }); err != nil { slog.WarnContext(ctx, fmt.Sprintf("Post processing failed for %s: %v", ad.CoerceAndRelayNTLMToSMB, err)) @@ -198,7 +199,7 @@ func PostNTLM(ctx context.Context, db graph.Database, localGroupData *LocalGroup continue } - if err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + if err = operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { return PostCoerceAndRelayNTLMToLDAP(outC, innerComputer, authenticatedUserGroupID, ntlmCache.LdapCache) }); err != nil { slog.WarnContext(ctx, fmt.Sprintf("Post processing failed for %s: %v", ad.CoerceAndRelayNTLMToLDAP, err)) @@ -378,12 +379,12 @@ func coerceAndRelayNTLMtoADCSPath2Pattern(domainID graph.ID, enterpriseCAs cardi )) } -func PostCoerceAndRelayNTLMToADCS(adcsCache ADCSCache, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], ntlmCache NTLMCache) error { +func PostCoerceAndRelayNTLMToADCS(adcsCache ADCSCache, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], ntlmCache NTLMCache) error { for _, outerDomain := range adcsCache.GetDomains() { for _, outerEnterpriseCA := range adcsCache.GetEnterpriseCertAuthorities() { domain := outerDomain enterpriseCA := outerEnterpriseCA - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if publishedCertTemplates := adcsCache.GetPublishedTemplateCache(enterpriseCA.ID); len(publishedCertTemplates) == 0 { // If this enterprise CA has no published templates, then there's no reason to check further return nil @@ -479,7 +480,7 @@ func PostCoerceAndRelayNTLMToADCS(adcsCache ADCSCache, operation analysis.StatTr } results.Each(func(value uint64) bool { - outC <- analysis.CreatePostRelationshipJob{ + outC <- post.EnsureRelationshipJob{ FromID: authUsersGroup, ToID: graph.ID(value), Kind: ad.CoerceAndRelayNTLMToADCS, @@ -582,7 +583,7 @@ func GetCoerceAndRelayNTLMtoSMBEdgeComposition(ctx context.Context, db graph.Dat // PostCoerceAndRelayNTLMToSMB creates edges that allow a computer with unrolled admin access to one or more computers where SMB signing is disabled. // Comprised solely of adminTo and memberOf edges -func PostCoerceAndRelayNTLMToSMB(tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob, ntlmCache NTLMCache, computer *graph.Node, authenticatedUserID graph.ID) error { +func PostCoerceAndRelayNTLMToSMB(tx graph.Transaction, outC chan<- post.EnsureRelationshipJob, ntlmCache NTLMCache, computer *graph.Node, authenticatedUserID graph.ID) error { if smbSigningEnabled, err := computer.Properties.Get(ad.SMBSigning.String()).Bool(); errors.Is(err, graph.ErrPropertyNotFound) { return nil } else if err != nil { @@ -608,7 +609,7 @@ func PostCoerceAndRelayNTLMToSMB(tx graph.Transaction, outC chan<- analysis.Crea allAdminPrincipals.Remove(computer.ID.Uint64()) if allAdminPrincipals.Cardinality() > 0 { - outC <- analysis.CreatePostRelationshipJob{ + outC <- post.EnsureRelationshipJob{ FromID: authenticatedUserID, ToID: computer.ID, Kind: ad.CoerceAndRelayNTLMToSMB, @@ -783,7 +784,7 @@ func GetCoercionTargetsForCoerceAndRelayNTLMtoSMB(ctx context.Context, db graph. // PostCoerceAndRelayNTLMToLDAP creates edges where an authenticated user group, for a given domain, is able to target the provided computer. // This will create either a CoerceAndRelayNTLMToLDAP or CoerceAndRelayNTLMToLDAPS edges, depending on the ldapSigning property of the domain -func PostCoerceAndRelayNTLMToLDAP(outC chan<- analysis.CreatePostRelationshipJob, computer *graph.Node, authenticatedUserGroupID graph.ID, ldapSigningCache map[string]LDAPSigningCache) error { +func PostCoerceAndRelayNTLMToLDAP(outC chan<- post.EnsureRelationshipJob, computer *graph.Node, authenticatedUserGroupID graph.ID, ldapSigningCache map[string]LDAPSigningCache) error { // webclientrunning must be set to true for the computer's properties in order for this attack path to be viable // If the property is not found, we will assume false if webClientRunning, err := computer.Properties.Get(ad.WebClientRunning.String()).Bool(); err != nil && !errors.Is(err, graph.ErrPropertyNotFound) { @@ -804,13 +805,13 @@ func PostCoerceAndRelayNTLMToLDAP(outC chan<- analysis.CreatePostRelationshipJob // for both LDAP and LDAPS scenarios, assuming the passed in signingCache has any vulnerable paths // We also ignore instances where the computer is relaying to itself if len(signingCache.relayableToDCLDAP) == 1 && signingCache.relayableToDCLDAP[0] != computer.ID { - outC <- analysis.CreatePostRelationshipJob{ + outC <- post.EnsureRelationshipJob{ FromID: authenticatedUserGroupID, ToID: computer.ID, Kind: ad.CoerceAndRelayNTLMToLDAP, } } else if len(signingCache.relayableToDCLDAP) > 1 { - outC <- analysis.CreatePostRelationshipJob{ + outC <- post.EnsureRelationshipJob{ FromID: authenticatedUserGroupID, ToID: computer.ID, Kind: ad.CoerceAndRelayNTLMToLDAP, @@ -818,13 +819,13 @@ func PostCoerceAndRelayNTLMToLDAP(outC chan<- analysis.CreatePostRelationshipJob } if len(signingCache.relayableToDCLDAPS) == 1 && signingCache.relayableToDCLDAPS[0] != computer.ID { - outC <- analysis.CreatePostRelationshipJob{ + outC <- post.EnsureRelationshipJob{ FromID: authenticatedUserGroupID, ToID: computer.ID, Kind: ad.CoerceAndRelayNTLMToLDAPS, } } else if len(signingCache.relayableToDCLDAPS) > 1 { - outC <- analysis.CreatePostRelationshipJob{ + outC <- post.EnsureRelationshipJob{ FromID: authenticatedUserGroupID, ToID: computer.ID, Kind: ad.CoerceAndRelayNTLMToLDAPS, diff --git a/packages/go/analysis/ad/owns.go b/packages/go/analysis/ad/owns.go index 5b10ac7588b..ffbd11d17c2 100644 --- a/packages/go/analysis/ad/owns.go +++ b/packages/go/analysis/ad/owns.go @@ -26,6 +26,7 @@ import ( "github.com/specterops/bloodhound/packages/go/analysis" "github.com/specterops/bloodhound/packages/go/analysis/ad/wellknown" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -36,7 +37,7 @@ import ( "github.com/specterops/dawgs/query" ) -func PostOwnsAndWriteOwner(ctx context.Context, db graph.Database, localGroupData *LocalGroupData) (*analysis.AtomicPostProcessingStats, error) { +func PostOwnsAndWriteOwner(ctx context.Context, db graph.Database, localGroupData *LocalGroupData) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -58,7 +59,7 @@ func PostOwnsAndWriteOwner(ctx context.Context, db graph.Database, localGroupDat } else { // Get all source nodes of Owns ACEs (i.e., owning principals) where the target node has no ACEs granting abusable explicit permissions to OWNER RIGHTS - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if relationships, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria { return query.And( query.Kind(query.Relationship(), ad.OwnsRaw), @@ -114,7 +115,7 @@ func PostOwnsAndWriteOwner(ctx context.Context, db graph.Database, localGroupDat } // Get all source nodes of WriteOwner ACEs where the target node has no ACEs granting explicit abusable permissions to OWNER RIGHTS - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if relationships, err := ops.FetchRelationships(tx.Relationships().Filterf(func() graph.Criteria { return query.And( @@ -173,11 +174,11 @@ func PostOwnsAndWriteOwner(ctx context.Context, db graph.Database, localGroupDat return &operation.Stats, operation.Done() } -func createPostRelFromRaw(rel *graph.Relationship, kind graph.Kind) analysis.CreatePostRelationshipJob { +func createPostRelFromRaw(rel *graph.Relationship, kind graph.Kind) post.EnsureRelationshipJob { isInherited, _ := rel.Properties.GetOrDefault(common.IsInherited.String(), false).Bool() inheritanceHash, _ := rel.Properties.GetOrDefault(ad.InheritanceHash.String(), "").String() - return analysis.CreatePostRelationshipJob{ + return post.EnsureRelationshipJob{ FromID: rel.StartID, ToID: rel.EndID, Kind: kind, diff --git a/packages/go/analysis/ad/post.go b/packages/go/analysis/ad/post.go index a7fc2129e38..95d021913b1 100644 --- a/packages/go/analysis/ad/post.go +++ b/packages/go/analysis/ad/post.go @@ -23,6 +23,7 @@ import ( "github.com/specterops/bloodhound/packages/go/analysis" "github.com/specterops/bloodhound/packages/go/analysis/ad/wellknown" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -36,7 +37,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostSyncLAPSPassword(ctx context.Context, db graph.Database, localGroupData *LocalGroupData) (*analysis.AtomicPostProcessingStats, error) { +func PostSyncLAPSPassword(ctx context.Context, db graph.Database, localGroupData *LocalGroupData) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -47,12 +48,12 @@ func PostSyncLAPSPassword(ctx context.Context, db graph.Database, localGroupData )() if domainNodes, err := fetchCollectedDomainNodes(ctx, db); err != nil { - return &analysis.AtomicPostProcessingStats{}, err + return &post.AtomicPostProcessingStats{}, err } else { operation := analysis.NewPostRelationshipOperation(ctx, db, "SyncLAPSPassword Post Processing") for _, domain := range domainNodes { innerDomain := domain - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if lapsSyncers, err := getLAPSSyncers(tx, innerDomain, localGroupData); err != nil { return err } else if lapsSyncers.Cardinality() == 0 { @@ -62,7 +63,7 @@ func PostSyncLAPSPassword(ctx context.Context, db graph.Database, localGroupData } else { for _, computer := range computers { lapsSyncers.Each(func(value uint64) bool { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: computer, Kind: ad.SyncLAPSPassword, @@ -80,7 +81,7 @@ func PostSyncLAPSPassword(ctx context.Context, db graph.Database, localGroupData } } -func PostDCSync(ctx context.Context, db graph.Database, localGroupData *LocalGroupData) (*analysis.AtomicPostProcessingStats, error) { +func PostDCSync(ctx context.Context, db graph.Database, localGroupData *LocalGroupData) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -91,20 +92,20 @@ func PostDCSync(ctx context.Context, db graph.Database, localGroupData *LocalGro )() if domainNodes, err := fetchCollectedDomainNodes(ctx, db); err != nil { - return &analysis.AtomicPostProcessingStats{}, err + return &post.AtomicPostProcessingStats{}, err } else { operation := analysis.NewPostRelationshipOperation(ctx, db, "DCSync Post Processing") for _, domain := range domainNodes { innerDomain := domain - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if dcSyncers, err := getDCSyncers(tx, innerDomain, localGroupData); err != nil { return err } else if dcSyncers.Cardinality() == 0 { return nil } else { dcSyncers.Each(func(value uint64) bool { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: graph.ID(value), ToID: innerDomain.ID, Kind: ad.DCSync, @@ -121,7 +122,7 @@ func PostDCSync(ctx context.Context, db graph.Database, localGroupData *LocalGro } } -func PostProtectAdminGroups(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { +func PostProtectAdminGroups(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -133,14 +134,14 @@ func PostProtectAdminGroups(ctx context.Context, db graph.Database) (*analysis.A domainNodes, err := fetchCollectedDomainNodes(ctx, db) if err != nil { - return &analysis.AtomicPostProcessingStats{}, err + return &post.AtomicPostProcessingStats{}, err } operation := analysis.NewPostRelationshipOperation(ctx, db, "ProtectAdminGroups Post Processing") for _, domain := range domainNodes { - operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if adminSDHolderIDs, err := getAdminSDHolder(tx, domain); graph.IsErrNotFound(err) { // No AdminSDHolder IDs found for this domain return nil @@ -154,7 +155,7 @@ func PostProtectAdminGroups(ctx context.Context, db graph.Database) (*analysis.A } else { fromID := adminSDHolderIDs[0] // AdminSDHolder should be unique per domain for _, toID := range protectedObjectIDs { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: fromID, ToID: toID, Kind: ad.ProtectAdminGroups, @@ -168,7 +169,7 @@ func PostProtectAdminGroups(ctx context.Context, db graph.Database) (*analysis.A return &operation.Stats, operation.Done() } -func PostHasTrustKeys(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { +func PostHasTrustKeys(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -179,10 +180,10 @@ func PostHasTrustKeys(ctx context.Context, db graph.Database) (*analysis.AtomicP )() if domainNodes, err := fetchCollectedDomainNodes(ctx, db); err != nil { - return &analysis.AtomicPostProcessingStats{}, err + return &post.AtomicPostProcessingStats{}, err } else { operation := analysis.NewPostRelationshipOperation(ctx, db, "HasTrustKeys Post Processing") - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, domain := range domainNodes { if netbios, err := domain.Properties.Get(ad.NetBIOS.String()).String(); err != nil { // The property is new and may therefore not exist @@ -202,7 +203,7 @@ func PostHasTrustKeys(ctx context.Context, db graph.Database) (*analysis.AtomicP slog.DebugContext(ctx, fmt.Sprintf("Trust account not found for domain SID %s and NetBIOS %s", trustingDomainSid, netbios)) continue } else { - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: domain.ID, ToID: trustAccount.ID, Kind: ad.HasTrustKeys, @@ -213,7 +214,7 @@ func PostHasTrustKeys(ctx context.Context, db graph.Database) (*analysis.AtomicP } return nil }); err != nil { - return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("error creating HasTrustKeys edges: %w", err) + return &post.AtomicPostProcessingStats{}, fmt.Errorf("error creating HasTrustKeys edges: %w", err) } return &operation.Stats, operation.Done() diff --git a/packages/go/analysis/azure/post.go b/packages/go/analysis/azure/post.go index d69eddf124d..d6e8326917e 100644 --- a/packages/go/analysis/azure/post.go +++ b/packages/go/analysis/azure/post.go @@ -23,10 +23,13 @@ import ( "strings" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/delta" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/azure" "github.com/specterops/bloodhound/packages/go/graphschema/common" + "github.com/specterops/bloodhound/packages/go/trace" "github.com/specterops/dawgs/cardinality" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" @@ -185,7 +188,7 @@ func aggregateSourceReadWriteServicePrincipals(tx graph.Transaction, tenantConta return sourceNodes, nil } -func AppRoleAssignments(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { +func AppRoleAssignments(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -196,7 +199,7 @@ func AppRoleAssignments(ctx context.Context, db graph.Database) (*analysis.Atomi )() if tenants, err := FetchTenants(ctx, db); err != nil { - return &analysis.AtomicPostProcessingStats{}, err + return &post.AtomicPostProcessingStats{}, err } else { operation := analysis.NewPostRelationshipOperation(ctx, db, "Azure App Role Assignments Post Processing") @@ -244,17 +247,17 @@ func AppRoleAssignments(ctx context.Context, db graph.Database) (*analysis.Atomi } } -func createAZMGApplicationReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGApplicationReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if tenantContainsAppRelationships, err := fetchTenantContainsRelationships(tx, tenant, azure.App); err != nil { return err } else if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.ApplicationReadWriteAll); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, targetRelationship := range append(tenantContainsServicePrincipalRelationships, tenantContainsAppRelationships...) { for _, sourceNode := range sourceNodes { - AZMGAddSecretRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddSecretRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: targetRelationship.EndID, Kind: azure.AZMGAddSecret, @@ -264,7 +267,7 @@ func createAZMGApplicationReadWriteAllEdges(ctx context.Context, db graph.Databa return nil } - AZMGAddOwnerRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddOwnerRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: targetRelationship.EndID, Kind: azure.AZMGAddOwner, @@ -286,15 +289,15 @@ func createAZMGApplicationReadWriteAllEdges(ctx context.Context, db graph.Databa } } -func createAZMGAppRoleAssignmentReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGAppRoleAssignmentReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.AppRoleAssignmentReadWriteAll); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsServicePrincipalRelationship := range tenantContainsServicePrincipalRelationships { for _, sourceNode := range sourceNodes { - AZMGGrantAppRolesRelationship := analysis.CreatePostRelationshipJob{ + AZMGGrantAppRolesRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsServicePrincipalRelationship.StartID, // the tenant Kind: azure.AZMGGrantAppRoles, @@ -316,17 +319,17 @@ func createAZMGAppRoleAssignmentReadWriteAllEdges(ctx context.Context, db graph. } } -func createAZMGDirectoryReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGDirectoryReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.DirectoryReadWriteAll); err != nil { return err } else if tenantContainsGroupRelationships, err := fetchTenantContainsReadWriteAllGroupRelationships(tx, tenant); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsGroupRelationship := range tenantContainsGroupRelationships { for _, sourceNode := range sourceNodes { - AZMGAddMemberRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddMemberRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsGroupRelationship.EndID, Kind: azure.AZMGAddMember, @@ -336,7 +339,7 @@ func createAZMGDirectoryReadWriteAllEdges(ctx context.Context, db graph.Database return nil } - AZMGAddOwnerRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddOwnerRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsGroupRelationship.EndID, Kind: azure.AZMGAddOwner, @@ -357,17 +360,17 @@ func createAZMGDirectoryReadWriteAllEdges(ctx context.Context, db graph.Database } } -func createAZMGGroupReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGGroupReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.GroupReadWriteAll); err != nil { return err } else if tenantContainsGroupRelationships, err := fetchTenantContainsReadWriteAllGroupRelationships(tx, tenant); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsGroupRelationship := range tenantContainsGroupRelationships { for _, sourceNode := range sourceNodes { - AZMGAddMemberRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddMemberRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsGroupRelationship.EndID, Kind: azure.AZMGAddMember, @@ -377,7 +380,7 @@ func createAZMGGroupReadWriteAllEdges(ctx context.Context, db graph.Database, op return nil } - AZMGAddOwnerRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddOwnerRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsGroupRelationship.EndID, Kind: azure.AZMGAddOwner, @@ -398,17 +401,17 @@ func createAZMGGroupReadWriteAllEdges(ctx context.Context, db graph.Database, op } } -func createAZMGGroupMemberReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGGroupMemberReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.GroupMemberReadWriteAll); err != nil { return err } else if tenantContainsGroupRelationships, err := fetchTenantContainsReadWriteAllGroupRelationships(tx, tenant); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsGroupRelationship := range tenantContainsGroupRelationships { for _, sourceNode := range sourceNodes { - AZMGAddMemberRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddMemberRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsGroupRelationship.EndID, Kind: azure.AZMGAddMember, @@ -429,17 +432,17 @@ func createAZMGGroupMemberReadWriteAllEdges(ctx context.Context, db graph.Databa } } -func createAZMGRoleManagementReadWriteDirectoryEdgesPart1(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGRoleManagementReadWriteDirectoryEdgesPart1(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.RoleManagementReadWriteDirectory); err != nil { return err } else if tenantContainsRoleRelationships, err := fetchTenantContainsRelationships(tx, tenant, azure.Role); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsRoleRelationship := range tenantContainsRoleRelationships { for _, sourceNode := range sourceNodes { - AZMGGrantAppRolesRelationship := analysis.CreatePostRelationshipJob{ + AZMGGrantAppRolesRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsRoleRelationship.StartID, Kind: azure.AZMGGrantAppRoles, @@ -460,17 +463,17 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart1(ctx context.Context, d } } -func createAZMGRoleManagementReadWriteDirectoryEdgesPart2(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGRoleManagementReadWriteDirectoryEdgesPart2(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.RoleManagementReadWriteDirectory); err != nil { return err } else if tenantContainsRoleRelationships, err := fetchTenantContainsRelationships(tx, tenant, azure.Role); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsRoleRelationship := range tenantContainsRoleRelationships { for _, sourceNode := range sourceNodes { - AZMGGrantRoleRelationship := analysis.CreatePostRelationshipJob{ + AZMGGrantRoleRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsRoleRelationship.EndID, Kind: azure.AZMGGrantRole, @@ -491,15 +494,15 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart2(ctx context.Context, d } } -func createAZMGRoleManagementReadWriteDirectoryEdgesPart3(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGRoleManagementReadWriteDirectoryEdgesPart3(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.RoleManagementReadWriteDirectory); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsServicePrincipalRelationship := range tenantContainsServicePrincipalRelationships { for _, sourceNode := range sourceNodes { - AZMGAddSecretRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddSecretRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsServicePrincipalRelationship.EndID, Kind: azure.AZMGAddSecret, @@ -509,7 +512,7 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart3(ctx context.Context, d return nil } - AZMGAddOwnerRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddOwnerRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsServicePrincipalRelationship.EndID, Kind: azure.AZMGAddOwner, @@ -531,17 +534,17 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart3(ctx context.Context, d } } -func createAZMGRoleManagementReadWriteDirectoryEdgesPart4(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGRoleManagementReadWriteDirectoryEdgesPart4(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.RoleManagementReadWriteDirectory); err != nil { return err } else if tenantContainsAppRelationships, err := fetchTenantContainsRelationships(tx, tenant, azure.App); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsAppRelationship := range tenantContainsAppRelationships { for _, sourceNode := range sourceNodes { - AZMGAddSecretRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddSecretRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsAppRelationship.EndID, Kind: azure.AZMGAddSecret, @@ -551,7 +554,7 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart4(ctx context.Context, d return nil } - AZMGAddOwnerRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddOwnerRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsAppRelationship.EndID, Kind: azure.AZMGAddOwner, @@ -573,17 +576,17 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart4(ctx context.Context, d } } -func createAZMGRoleManagementReadWriteDirectoryEdgesPart5(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGRoleManagementReadWriteDirectoryEdgesPart5(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node, tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.RoleManagementReadWriteDirectory); err != nil { return err } else if tenantContainsGroupRelationships, err := fetchTenantContainsRelationships(tx, tenant, azure.Group); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsGroupRelationship := range tenantContainsGroupRelationships { for _, sourceNode := range sourceNodes { - AZMGAddMemberRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddMemberRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsGroupRelationship.EndID, Kind: azure.AZMGAddMember, @@ -593,7 +596,7 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart5(ctx context.Context, d return nil } - AZMGAddOwnerRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddOwnerRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsGroupRelationship.EndID, Kind: azure.AZMGAddOwner, @@ -614,15 +617,15 @@ func createAZMGRoleManagementReadWriteDirectoryEdgesPart5(ctx context.Context, d } } -func createAZMGServicePrincipalEndpointReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenantContainsServicePrincipalRelationships []*graph.Relationship) error { +func createAZMGServicePrincipalEndpointReadWriteAllEdges(ctx context.Context, db graph.Database, operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenantContainsServicePrincipalRelationships []*graph.Relationship) error { if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { if sourceNodes, err := aggregateSourceReadWriteServicePrincipals(tx, tenantContainsServicePrincipalRelationships, azure.ServicePrincipalEndpointReadWriteAll); err != nil { return err } else { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for _, tenantContainsServicePrincipalRelationship := range tenantContainsServicePrincipalRelationships { for _, sourceNode := range sourceNodes { - AZMGAddOwnerRelationship := analysis.CreatePostRelationshipJob{ + AZMGAddOwnerRelationship := post.EnsureRelationshipJob{ FromID: sourceNode.ID, ToID: tenantContainsServicePrincipalRelationship.EndID, Kind: azure.AZMGAddOwner, @@ -644,8 +647,8 @@ func createAZMGServicePrincipalEndpointReadWriteAllEdges(ctx context.Context, db } } -func addSecret(operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node) error { - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { +func addSecret(operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], tenant *graph.Node) error { + return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if addSecretRoles, err := TenantRoles(tx, tenant, AddSecretRoleIDs()...); err != nil { return err } else if tenantAppsAndSPs, err := TenantApplicationsAndServicePrincipals(tx, tenant); err != nil { @@ -660,7 +663,7 @@ func addSecret(operation analysis.StatTrackedOperation[analysis.CreatePostRelati slog.String("target_kinds", strings.Join(target.Kinds.Strings(), ",")), slog.Any("target_id", target.ID), ) - nextJob := analysis.CreatePostRelationshipJob{ + nextJob := post.EnsureRelationshipJob{ FromID: role.ID, ToID: target.ID, Kind: azure.AddSecret, @@ -677,7 +680,7 @@ func addSecret(operation analysis.StatTrackedOperation[analysis.CreatePostRelati }) } -func ExecuteCommand(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { +func ExecuteCommand(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -688,7 +691,7 @@ func ExecuteCommand(ctx context.Context, db graph.Database) (*analysis.AtomicPos )() if tenants, err := FetchTenants(ctx, db); err != nil { - return &analysis.AtomicPostProcessingStats{}, err + return &post.AtomicPostProcessingStats{}, err } else { operation := analysis.NewPostRelationshipOperation(ctx, db, "AZExecuteCommand Post Processing") if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { @@ -703,12 +706,12 @@ func ExecuteCommand(ctx context.Context, db graph.Database) (*analysis.AtomicPos for _, tenantDevice := range tenantDevices { innerTenantDevice := tenantDevice - if err := operation.Operation.SubmitReader(func(ctx context.Context, _ graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + if err := operation.Operation.SubmitReader(func(ctx context.Context, _ graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { if isWindowsDevice, err := IsWindowsDevice(innerTenantDevice); err != nil { return err } else if isWindowsDevice { for _, intuneAdmin := range intuneAdmins { - nextJob := analysis.CreatePostRelationshipJob{ + nextJob := post.EnsureRelationshipJob{ FromID: intuneAdmin.ID, ToID: innerTenantDevice.ID, Kind: azure.ExecuteCommand, @@ -741,16 +744,10 @@ func ExecuteCommand(ctx context.Context, db graph.Database) (*analysis.AtomicPos } } -func resetPassword(operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], tenant *graph.Node, roleAssignments RoleAssignments) error { - defer measure.LogAndMeasure( - slog.LevelInfo, - "AZResetPassword Post Processing", - attr.Namespace("analysis"), - attr.Function("resetPassword"), - attr.Scope("routine"), - )() +func postAzureResetPassword(ctx context.Context, db graph.Database, sink *post.FilteredRelationshipSink, tenant *graph.Node, roleAssignments RoleAssignments) error { + defer trace.Function(ctx, "postAzureResetPassword", attr.Operation("Reset Password Post Processing"))() - return operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + return db.ReadTransaction(ctx, func(tx graph.Transaction) error { if pwResetRoles, err := TenantRoles(tx, tenant, ResetPasswordRoleIDs()...); err != nil { return err } else { @@ -759,17 +756,18 @@ func resetPassword(operation analysis.StatTrackedOperation[analysis.CreatePostRe return fmt.Errorf("unable to continue processing azresetpassword for tenant node %d: %w", tenant.ID, err) } else { targets.Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ + nextJob := post.EnsureRelationshipJob{ FromID: role.ID, ToID: graph.ID(nextID), Kind: azure.ResetPassword, } - return channels.Submit(ctx, outC, nextJob) + return sink.Submit(ctx, nextJob) }) } } } + return nil }) } @@ -811,230 +809,163 @@ func resetPasswordEndNodeBitmapForRole(role *graph.Node, roleAssignments RoleAss } } -func globalAdmins(roleAssignments RoleAssignments, tenant *graph.Node, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob]) { - defer measure.LogAndMeasure( - slog.LevelInfo, - "Global Admins Post Processing", - attr.Namespace("analysis"), - attr.Function("globalAdmins"), - attr.Scope("routine"), - )() +func postAzureGlobalAdmins(ctx context.Context, sink *post.FilteredRelationshipSink, roleAssignments RoleAssignments, tenant *graph.Node) error { + defer trace.Function(ctx, "postAzureGlobalAdmins", attr.Operation("Global Admins Post Processing"))() - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { - roleAssignments.PrincipalsWithRole(azure.CompanyAdministratorRole).Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ - FromID: graph.ID(nextID), - ToID: tenant.ID, - Kind: azure.GlobalAdmin, - } + roleAssignments.PrincipalsWithRole(azure.CompanyAdministratorRole).Each(func(nextID uint64) bool { + nextJob := post.EnsureRelationshipJob{ + FromID: graph.ID(nextID), + ToID: tenant.ID, + Kind: azure.GlobalAdmin, + } - return channels.Submit(ctx, outC, nextJob) - }) + return sink.Submit(ctx, nextJob) + }) - return nil - }); err != nil { - slog.Error("Failed to submit azure global admins post processing job", attr.Error(err)) - } + return nil } -func privilegedRoleAdmins(roleAssignments RoleAssignments, tenant *graph.Node, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob]) { - defer measure.LogAndMeasure( - slog.LevelInfo, - "Privileged Role Admins Post Processing", - attr.Namespace("analysis"), - attr.Function("privilegedRoleAdmins"), - attr.Scope("routine"), - )() +func postAzurePrivilegedRoleAdmins(ctx context.Context, sink *post.FilteredRelationshipSink, roleAssignments RoleAssignments, tenant *graph.Node) { + defer trace.Function(ctx, "postAzurePrivilegedRoleAdmins", attr.Operation("Privileged Role Admins Post Processing"))() - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { - roleAssignments.PrincipalsWithRole(azure.PrivilegedRoleAdministratorRole).Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ - FromID: graph.ID(nextID), - ToID: tenant.ID, - Kind: azure.PrivilegedRoleAdmin, - } + roleAssignments.PrincipalsWithRole(azure.PrivilegedRoleAdministratorRole).Each(func(nextID uint64) bool { + nextJob := post.EnsureRelationshipJob{ + FromID: graph.ID(nextID), + ToID: tenant.ID, + Kind: azure.PrivilegedRoleAdmin, + } - return channels.Submit(ctx, outC, nextJob) - }) + return sink.Submit(ctx, nextJob) + }) +} - return nil - }); err != nil { - slog.Error("Failed to submit privileged role admins post processing job", attr.Error(err)) - } +func postAzurePrivilegedAuthAdmins(ctx context.Context, sink *post.FilteredRelationshipSink, roleAssignments RoleAssignments, tenant *graph.Node) { + defer trace.Function(ctx, "postAzurePrivilegedAuthAdmins", attr.Operation("Privileged Auth Admins Post Processing"))() + + roleAssignments.PrincipalsWithRole(azure.PrivilegedAuthenticationAdministratorRole).Each(func(nextID uint64) bool { + nextJob := post.EnsureRelationshipJob{ + FromID: graph.ID(nextID), + ToID: tenant.ID, + Kind: azure.PrivilegedAuthAdmin, + } + + return sink.Submit(ctx, nextJob) + }) } -func privilegedAuthAdmins(roleAssignments RoleAssignments, tenant *graph.Node, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob]) { - defer measure.LogAndMeasure( - slog.LevelInfo, - "Privileged Auth Admins Post Processing", - attr.Namespace("analysis"), - attr.Function("privilegedAuthAdmins"), - attr.Scope("routine"), - )() +func postAzureAddMembers(ctx context.Context, sink *post.FilteredRelationshipSink, roleAssignments RoleAssignments) error { + defer trace.Function(ctx, "postAzureAddMembers", attr.Operation("Azure Add Members Post Processing"))() - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { - roleAssignments.PrincipalsWithRole(azure.PrivilegedAuthenticationAdministratorRole).Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ + for tenantGroupID, tenantGroup := range roleAssignments.TenantPrincipals.Get(azure.Group) { + roleAssignments.UsersWithRole(AddMemberAllGroupsTargetRoles()...).Each(func(nextID uint64) bool { + nextJob := post.EnsureRelationshipJob{ FromID: graph.ID(nextID), - ToID: tenant.ID, - Kind: azure.PrivilegedAuthAdmin, + ToID: tenantGroupID, + Kind: azure.AddMembers, } - return channels.Submit(ctx, outC, nextJob) + return sink.Submit(ctx, nextJob) }) - return nil - }); err != nil { - slog.Error("Failed to submit azure privileged auth admins post processing job", attr.Error(err)) - } -} - -func addMembers(roleAssignments RoleAssignments, operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob]) { - defer measure.LogAndMeasure( - slog.LevelInfo, - "AZ Add Members Post Processing", - attr.Namespace("analysis"), - attr.Function("addMembers"), - attr.Scope("routine"), - )() + roleAssignments.ServicePrincipalsWithRole(AddMemberAllGroupsTargetRoles()...).Each(func(nextID uint64) bool { + nextJob := post.EnsureRelationshipJob{ + FromID: graph.ID(nextID), + ToID: tenantGroupID, + Kind: azure.AddMembers, + } - for tenantGroupID, tenantGroup := range roleAssignments.Principals.Get(azure.Group) { - var ( - innerGroupID = tenantGroupID - innerGroup = tenantGroup - ) + return sink.Submit(ctx, nextJob) + }) - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { - roleAssignments.UsersWithRole(AddMemberAllGroupsTargetRoles()...).Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ + if isRoleAssignable, err := tenantGroup.Properties.Get(azure.IsAssignableToRole.String()).Bool(); err != nil { + if graph.IsErrPropertyNotFound(err) { + slog.WarnContext( + ctx, + "Node is missing property", + slog.Uint64("node_id", tenantGroup.ID.Uint64()), + slog.String("property", azure.IsAssignableToRole.String()), + ) + } else { + return err + } + } else if !isRoleAssignable { + roleAssignments.UsersWithRole(AddMemberGroupNotRoleAssignableTargetRoles()...).Each(func(nextID uint64) bool { + nextJob := post.EnsureRelationshipJob{ FromID: graph.ID(nextID), - ToID: innerGroupID, + ToID: tenantGroupID, Kind: azure.AddMembers, } - return channels.Submit(ctx, outC, nextJob) + return sink.Submit(ctx, nextJob) }) - - return nil - }); err != nil { - slog.Error("Failed to submit post processing job for users with role allowing AZAddMembers edge", attr.Error(err)) } - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { - roleAssignments.ServicePrincipalsWithRole(AddMemberAllGroupsTargetRoles()...).Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ + if isRoleAssignable, err := tenantGroup.Properties.Get(azure.IsAssignableToRole.String()).Bool(); err != nil { + if graph.IsErrPropertyNotFound(err) { + slog.WarnContext( + ctx, + "Node is missing property", + slog.Uint64("node_id", tenantGroup.ID.Uint64()), + slog.String("property", azure.IsAssignableToRole.String()), + ) + } else { + return err + } + } else if !isRoleAssignable { + roleAssignments.ServicePrincipalsWithRole(AddMemberGroupNotRoleAssignableTargetRoles()...).Each(func(nextID uint64) bool { + nextJob := post.EnsureRelationshipJob{ FromID: graph.ID(nextID), - ToID: innerGroupID, + ToID: tenantGroupID, Kind: azure.AddMembers, } - return channels.Submit(ctx, outC, nextJob) + return sink.Submit(ctx, nextJob) }) - - return nil - }); err != nil { - slog.Error("Failed to submit post processing job for service principals with role allowing AZAddMembers edge", attr.Error(err)) - } - - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { - if isRoleAssignable, err := innerGroup.Properties.Get(azure.IsAssignableToRole.String()).Bool(); err != nil { - if graph.IsErrPropertyNotFound(err) { - slog.WarnContext( - ctx, - "Node is missing property", - slog.Uint64("node_id", innerGroup.ID.Uint64()), - slog.String("property", azure.IsAssignableToRole.String()), - ) - } else { - return err - } - } else if !isRoleAssignable { - roleAssignments.UsersWithRole(AddMemberGroupNotRoleAssignableTargetRoles()...).Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ - FromID: graph.ID(nextID), - ToID: innerGroupID, - Kind: azure.AddMembers, - } - - return channels.Submit(ctx, outC, nextJob) - }) - } - - return nil - }); err != nil { - slog.Error("Failed to submit post processing job for users with role allowing AZAddMembers edge", attr.Error(err)) } + } - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { - if isRoleAssignable, err := innerGroup.Properties.Get(azure.IsAssignableToRole.String()).Bool(); err != nil { - if graph.IsErrPropertyNotFound(err) { - slog.WarnContext( - ctx, - "Node is missing property", - slog.Uint64("node_id", innerGroup.ID.Uint64()), - slog.String("property", azure.IsAssignableToRole.String()), - ) - } else { - return err - } - } else if !isRoleAssignable { - roleAssignments.ServicePrincipalsWithRole(AddMemberGroupNotRoleAssignableTargetRoles()...).Each(func(nextID uint64) bool { - nextJob := analysis.CreatePostRelationshipJob{ - FromID: graph.ID(nextID), - ToID: innerGroupID, - Kind: azure.AddMembers, - } - - return channels.Submit(ctx, outC, nextJob) - }) - } + return nil +} - return nil - }); err != nil { - slog.Error("Failed to submit post processing job for service principals with role allowing AZAddMembers edge", attr.Error(err)) - } - } +var userRoleAssignmentPostProcessedEdges = graph.Kinds{ + azure.ResetPassword, + azure.GlobalAdmin, + azure.PrivilegedRoleAdmin, + azure.PrivilegedAuthAdmin, + azure.AddMembers, } -func UserRoleAssignments(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { - defer measure.ContextLogAndMeasure( - ctx, - slog.LevelInfo, - "Post-processing User Role Assignments", - attr.Namespace("analysis"), - attr.Function("UserRoleAssignments"), - attr.Scope("process"), - )() +func UserRoleAssignments(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) { + ctx = trace.Context(ctx, slog.LevelInfo, "analysis", "Azure User Role Assignment Post-processing") + defer trace.Function(ctx, "UserRoleAssignments")() - if tenantNodes, err := FetchTenants(ctx, db); err != nil { - return &analysis.AtomicPostProcessingStats{}, err + if userRoleAssignmentTracker, err := delta.FetchTracker(ctx, db, userRoleAssignmentPostProcessedEdges); err != nil { + return &post.AtomicPostProcessingStats{}, err + } else if tenantNodes, err := FetchTenants(ctx, db); err != nil { + return &post.AtomicPostProcessingStats{}, err } else { - operation := analysis.NewPostRelationshipOperation(ctx, db, "Azure User Role Assignments Post Processing") + sink := post.NewFilteredRelationshipSink(ctx, "Azure User Role Assignments Post Processing", db, userRoleAssignmentTracker) + defer sink.Done() for _, tenant := range tenantNodes { - if roleAssignments, err := TenantRoleAssignments(ctx, db, tenant); err != nil { - if err := operation.Done(); err != nil { - slog.ErrorContext(ctx, "Error caught during azure UserRoleAssignments.TenantRoleAssignments teardown", attr.Error(err)) - } - - return &analysis.AtomicPostProcessingStats{}, err + if roleAssignments, err := FetchTenantRoleAssignments(ctx, db, tenant); err != nil { + return &post.AtomicPostProcessingStats{}, err } else { - if err := resetPassword(operation, tenant, roleAssignments); err != nil { - if err := operation.Done(); err != nil { - slog.ErrorContext(ctx, "Error caught during azure UserRoleAssignments.resetPassword teardown", attr.Error(err)) - } - - return &analysis.AtomicPostProcessingStats{}, err + if err := postAzureResetPassword(ctx, db, sink, tenant, roleAssignments); err != nil { + return &post.AtomicPostProcessingStats{}, err } else { - globalAdmins(roleAssignments, tenant, operation) - privilegedRoleAdmins(roleAssignments, tenant, operation) - privilegedAuthAdmins(roleAssignments, tenant, operation) - addMembers(roleAssignments, operation) + postAzureGlobalAdmins(ctx, sink, roleAssignments, tenant) + postAzurePrivilegedRoleAdmins(ctx, sink, roleAssignments, tenant) + postAzurePrivilegedAuthAdmins(ctx, sink, roleAssignments, tenant) + + if err := postAzureAddMembers(ctx, sink, roleAssignments); err != nil { + slog.Error("Azure AddMember Post-Processing Failure", attr.Error(err)) + } } } } - return &operation.Stats, operation.Done() + return sink.Stats(), nil } } @@ -1063,7 +994,7 @@ func CreateAZRoleApproverEdge( ctx context.Context, db graph.Database, ) ( - *analysis.AtomicPostProcessingStats, + *post.AtomicPostProcessingStats, error, ) { defer measure.ContextLogAndMeasure( diff --git a/packages/go/analysis/azure/post_test.go b/packages/go/analysis/azure/post_test.go index 4deef5170d7..127c189794a 100644 --- a/packages/go/analysis/azure/post_test.go +++ b/packages/go/analysis/azure/post_test.go @@ -55,8 +55,8 @@ func setupRoleAssignments() azure.RoleAssignments { return azure.RoleAssignments{ // user2 has no roles! this is intentional - Principals: graph.NewNodeSet(user, user2, group, app).KindSet(), - RoleMap: roleMap, + TenantPrincipals: graph.NewNodeSet(user, user2, group, app).KindSet(), + RoleMap: roleMap, } } diff --git a/packages/go/analysis/azure/role.go b/packages/go/analysis/azure/role.go index b905107e439..b4ba9a5e42e 100644 --- a/packages/go/analysis/azure/role.go +++ b/packages/go/analysis/azure/role.go @@ -24,6 +24,7 @@ import ( "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/azure" + "github.com/specterops/bloodhound/packages/go/trace" "github.com/specterops/dawgs/cardinality" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" @@ -100,7 +101,11 @@ func (s RoleAssignmentMap) HasRole(id graph.ID, roleTemplateIDs ...string) bool } type RoleAssignments struct { - Principals graph.NodeKindSet + TenantPrincipals graph.NodeKindSet + users cardinality.ImmutableDuplex[uint64] + usersWithAnyRole cardinality.ImmutableDuplex[uint64] + usersWithoutRoles cardinality.ImmutableDuplex[uint64] + servicePrincipals cardinality.ImmutableDuplex[uint64] RoleMap map[string]cardinality.Duplex[uint64] RoleAssignableGroupMembership cardinality.Duplex[uint64] } @@ -109,7 +114,7 @@ func (s RoleAssignments) GetNodeKindSet(bm cardinality.Duplex[uint64]) graph.Nod result := graph.NewNodeKindSet() bm.Each(func(nextID uint64) bool { - node := s.Principals.GetNode(graph.ID(nextID)) + node := s.TenantPrincipals.GetNode(graph.ID(nextID)) result.Add(node) return true @@ -122,29 +127,20 @@ func (s RoleAssignments) GetNodeSet(bm cardinality.Duplex[uint64]) graph.NodeSet return s.GetNodeKindSet(bm).AllNodes() } -func (s RoleAssignments) ServicePrincipals() cardinality.Duplex[uint64] { - return s.Principals.Get(azure.ServicePrincipal).IDBitmap() +func (s RoleAssignments) ServicePrincipals() cardinality.ImmutableDuplex[uint64] { + return s.servicePrincipals } -func (s RoleAssignments) Users() cardinality.Duplex[uint64] { - return s.Principals.Get(azure.User).IDBitmap() +func (s RoleAssignments) Users() cardinality.ImmutableDuplex[uint64] { + return s.users } -func (s RoleAssignments) UsersWithAnyRole() cardinality.Duplex[uint64] { - users := s.Users() - - principalsWithRoles := cardinality.NewBitmap64() - for _, bitmap := range s.RoleMap { - principalsWithRoles.Or(bitmap) - } - principalsWithRoles.And(users) - return principalsWithRoles +func (s RoleAssignments) UsersWithAnyRole() cardinality.ImmutableDuplex[uint64] { + return s.usersWithAnyRole } -func (s RoleAssignments) UsersWithoutRoles() cardinality.Duplex[uint64] { - result := s.Users() - result.AndNot(s.UsersWithAnyRole()) - return result +func (s RoleAssignments) UsersWithoutRoles() cardinality.ImmutableDuplex[uint64] { + return s.usersWithoutRoles } func (s RoleAssignments) UsersWithRole(roleTemplateIDs ...string) cardinality.Duplex[uint64] { @@ -215,38 +211,73 @@ func (s RoleAssignments) NodeHasRole(id graph.ID, roleTemplateIDs ...string) boo return false } -func initTenantRoleAssignments(tx graph.Transaction, tenant *graph.Node) (RoleAssignments, error) { - if !IsTenantNode(tenant) { - return RoleAssignments{}, fmt.Errorf("cannot initialize tenant role assignments - node %d must be of kind %s", tenant.ID, azure.Tenant) - } else if roleMembers, err := TenantPrincipals(tx, tenant); err != nil && !graph.IsErrNotFound(err) { - return RoleAssignments{}, err - } else { - return RoleAssignments{ - Principals: roleMembers.KindSet(), - RoleMap: make(map[string]cardinality.Duplex[uint64]), - RoleAssignableGroupMembership: cardinality.NewBitmap64(), - }, nil +func NewTenantRoleAssignments(tenant *graph.Node, tenantPrincipals graph.NodeKindSet, roleAssignableGroupMembership cardinality.Duplex[uint64], roleMap map[string]cardinality.Duplex[uint64]) RoleAssignments { + var ( + users = tenantPrincipals.Get(azure.User).IDBitmap() + usersWithAnyRole = cardinality.NewBitmap64() + usersWithoutRoles = cardinality.NewBitmap64() + servicePrincipals = tenantPrincipals.Get(azure.ServicePrincipal).IDBitmap() + ) + + // Calculate users with any role first + for _, bitmap := range roleMap { + usersWithAnyRole.Or(bitmap) + } + + usersWithAnyRole.And(users) + + // Calculate users without roles next + usersWithoutRoles.Or(users) + usersWithoutRoles.AndNot(usersWithAnyRole) + + slog.Info("Tenant Role Assignment Details", + slog.Uint64("num_users", users.Cardinality()), + slog.Uint64("num_service_principals", servicePrincipals.Cardinality()), + ) + + return RoleAssignments{ + TenantPrincipals: tenantPrincipals, + users: users, + usersWithAnyRole: usersWithAnyRole, + usersWithoutRoles: usersWithoutRoles, + servicePrincipals: servicePrincipals, + RoleMap: roleMap, + RoleAssignableGroupMembership: roleAssignableGroupMembership, } } -func TenantRoleAssignments(ctx context.Context, db graph.Database, tenant *graph.Node) (RoleAssignments, error) { +func FetchTenantRoleAssignments(ctx context.Context, db graph.Database, tenant *graph.Node) (RoleAssignments, error) { + defer trace.Function(ctx, "FetchTenantRoleAssignments")() + var roleAssignments RoleAssignments - return roleAssignments, db.ReadTransaction(ctx, func(tx graph.Transaction) error { - if fetchedRoleAssignments, err := initTenantRoleAssignments(tx, tenant); err != nil { + + if !IsTenantNode(tenant) { + return RoleAssignments{}, fmt.Errorf("cannot initialize tenant role assignments - node %d must be of kind %s", tenant.ID, azure.Tenant) + } + + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + if tenantPrincipalsNodeSet, err := TenantPrincipals(tx, tenant); err != nil && !graph.IsErrNotFound(err) { return err } else if roles, err := TenantRoles(tx, tenant); err != nil { return err } else { + var ( + tenantPrincipalsNodeKindSet = tenantPrincipalsNodeSet.KindSet() + roleAssignableGroupMembership = cardinality.NewBitmap64() + roleMap = map[string]cardinality.Duplex[uint64]{} + ) + // for each of the role assignable groups returned, fetch the users who are members - for _, group := range fetchedRoleAssignments.Principals.Get(azure.Group) { + for _, group := range tenantPrincipalsNodeKindSet.Get(azure.Group) { if members, err := FetchRoleAssignableGroupMembersUsers(tx, group, 0, 0); err != nil { return err } else { // set all users who have role assignable group membership - fetchedRoleAssignments.RoleAssignableGroupMembership.Or(members.IDBitmap()) + roleAssignableGroupMembership.Or(members.IDBitmap()) } } - return roles.KindSet().EachNode(func(node *graph.Node) error { + + for _, node := range roles { if roleTemplateID, err := node.Properties.Get(azure.RoleTemplateID.String()).String(); err != nil { if !graph.IsErrPropertyNotFound(err) { return err @@ -256,13 +287,18 @@ func TenantRoleAssignments(ctx context.Context, db graph.Database, tenant *graph return err } } else { - fetchedRoleAssignments.RoleMap[roleTemplateID] = members.IDBitmap() + roleMap[roleTemplateID] = members.IDBitmap() } - roleAssignments = fetchedRoleAssignments - return nil - }) + } + + roleAssignments = NewTenantRoleAssignments(tenant, tenantPrincipalsNodeKindSet, roleAssignableGroupMembership, roleMap) + return nil } - }) + }); err != nil { + return RoleAssignments{}, err + } + + return roleAssignments, nil } // RoleMembers returns the NodeSet of members for a given set of roles diff --git a/packages/go/analysis/azure/role_approver.go b/packages/go/analysis/azure/role_approver.go index e1ef1de02c6..a7bf6cc3765 100644 --- a/packages/go/analysis/azure/role_approver.go +++ b/packages/go/analysis/azure/role_approver.go @@ -21,6 +21,7 @@ import ( "log/slog" "github.com/specterops/bloodhound/packages/go/analysis" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema/azure" "github.com/specterops/bloodhound/packages/go/graphschema/common" "github.com/specterops/dawgs/graph" @@ -56,7 +57,7 @@ func CreateApproverEdge( ctx context.Context, db graph.Database, tenantNode *graph.Node, - operation analysis.StatTrackedOperation[analysis.CreatePostRelationshipJob], + operation analysis.StatTrackedOperation[post.EnsureRelationshipJob], ) error { // Extract the tenant's objectid to match against AZRole tenantid properties tenantObjectID, err := tenantNode.Properties.Get(common.ObjectID.String()).String() @@ -99,7 +100,7 @@ func CreateApproverEdge( if err := operation.Operation.SubmitReader(func( ctx context.Context, tx graph.Transaction, - outC chan<- analysis.CreatePostRelationshipJob, + outC chan<- post.EnsureRelationshipJob, ) error { // Step 3a: Read the primaryApprovers lists (user and group GUIDs) userApproversID, err := fetchedAZRole.Properties.Get( @@ -146,7 +147,7 @@ func CreateApproverEdge( func handleDefaultAdminRoles( ctx context.Context, db graph.Database, - outC chan<- analysis.CreatePostRelationshipJob, + outC chan<- post.EnsureRelationshipJob, tenantNode, fetchedAZRole *graph.Node, ) error { // Step 3b.ii: Find Global Administrator and Privileged Role Administrator roles in this tenant @@ -172,7 +173,7 @@ func handleDefaultAdminRoles( // Step 3b.iii: Create AZRoleApprover edges from each default admin role to the target AZRole for _, fetchedNode := range fetchedNodes { // Enqueue creation of AZRoleApprover edge: from admin role → target AZRole - channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: fetchedNode.ID, ToID: fetchedAZRole.ID, Kind: azure.AZRoleApprover, @@ -196,7 +197,7 @@ func handleDefaultAdminRoles( func handlePrincipalApprovers( ctx context.Context, db graph.Database, - outC chan<- analysis.CreatePostRelationshipJob, + outC chan<- post.EnsureRelationshipJob, principalIDs []string, fetchedAZRole *graph.Node, ) error { @@ -235,7 +236,7 @@ func handlePrincipalApprovers( } // Step 3c.ii.2: Create AZRoleApprover edge from approver node to target AZRole - if !channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ + if !channels.Submit(ctx, outC, post.EnsureRelationshipJob{ FromID: fetchedNode.ID, ToID: fetchedAZRole.ID, Kind: azure.AZRoleApprover, diff --git a/packages/go/analysis/azure/tenant.go b/packages/go/analysis/azure/tenant.go index c220560f110..1422b1b4761 100644 --- a/packages/go/analysis/azure/tenant.go +++ b/packages/go/analysis/azure/tenant.go @@ -23,6 +23,7 @@ import ( "github.com/specterops/bloodhound/packages/go/bhlog/measure" "github.com/specterops/bloodhound/packages/go/graphschema/azure" + "github.com/specterops/bloodhound/packages/go/trace" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" "github.com/specterops/dawgs/query" @@ -84,6 +85,8 @@ func TenantPrincipals(tx graph.Transaction, tenant *graph.Node) (graph.NodeSet, } func FetchTenants(ctx context.Context, db graph.Database) (graph.NodeSet, error) { + defer trace.Function(ctx, "FetchTenants")() + var nodeSet graph.NodeSet if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { var err error diff --git a/packages/go/analysis/delta/tracker.go b/packages/go/analysis/delta/tracker.go new file mode 100644 index 00000000000..4d1a273636b --- /dev/null +++ b/packages/go/analysis/delta/tracker.go @@ -0,0 +1,277 @@ +// 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 delta + +import ( + "context" + "encoding/binary" + "log/slog" + "slices" + "sync" + + "github.com/cespare/xxhash/v2" + "github.com/specterops/bloodhound/packages/go/trace" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/query" +) + +// KeyEncoder encodes node and edge identifiers into hash keys using xxhash. It is used +// to generate unique keys for tracking entities in a Tracker. +type KeyEncoder struct { + digester *xxhash.Digest + buffer [8]byte +} + +// NewKeyEncoder creates a new key encoder instance with default settings. +func NewKeyEncoder() *KeyEncoder { + return &KeyEncoder{ + digester: xxhash.New(), + } +} + +// NodeKey computes the hash key for a given node ID and list of kinds. +func (s *KeyEncoder) NodeKey(node uint64, kinds graph.Kinds) uint64 { + s.digester.Reset() + + // Node identifier and sorted kinds make up a node key + binary.LittleEndian.PutUint64(s.buffer[:], node) + s.digester.Write(s.buffer[:]) + + kindStrs := kinds.Strings() + slices.Sort(kindStrs) + + for _, kindStr := range kindStrs { + s.digester.WriteString(kindStr) + } + + // Sum the digest + return s.digester.Sum64() +} + +// EdgeKey computes the hash key for an edge defined by start/end IDs and kind. +func (s *KeyEncoder) EdgeKey(start, end uint64, kind graph.Kind) uint64 { + s.digester.Reset() + + // Start and end identifiers and the edge's kind make up an edge key + binary.LittleEndian.PutUint64(s.buffer[:], start) + s.digester.Write(s.buffer[:]) + + binary.LittleEndian.PutUint64(s.buffer[:], end) + s.digester.Write(s.buffer[:]) + + // Edge type + s.digester.WriteString(kind.String()) + + // Sum the digest + return s.digester.Sum64() +} + +// KeyEncoderPool manages a pool of KeyEncoder instances for efficient reuse. +type KeyEncoderPool struct { + encoders *sync.Pool +} + +// NewEdgeEncoderPool creates a new pool for edge key encoding. +func NewEdgeEncoderPool() *KeyEncoderPool { + return &KeyEncoderPool{ + encoders: &sync.Pool{ + New: func() any { + return NewKeyEncoder() + }, + }, + } +} + +// EdgeKey retrieves or allocates an encoder from the pool and computes the edge key. +func (s *KeyEncoderPool) EdgeKey(start, end uint64, kind graph.Kind) uint64 { + var ( + raw = s.encoders.Get() + encoder, typeOK = raw.(*KeyEncoder) + ) + + if !typeOK { + encoder = NewKeyEncoder() + } + + key := encoder.EdgeKey(start, end, kind) + + if typeOK { + s.encoders.Put(raw) + } + + return key +} + +// trackedEntity represents a single entity being tracked within a Tracker. +type trackedEntity struct { + ID uint64 + Key uint64 +} + +// Tracker tracks edges and nodes as hashed keys that can be looked and checked. The Tracker also +// maintains a list of seen entities and provides methods to detect deletions. +type Tracker struct { + entities []trackedEntity + seenKeys map[uint64]struct{} + seenKeysLock sync.RWMutex + encoderPool *KeyEncoderPool +} + +// Seen returns the number of unique keys currently tracked and seen by either HasNode or HasEdge. +func (s *Tracker) Seen() int { + s.seenKeysLock.RLock() + defer s.seenKeysLock.RUnlock() + + return len(s.seenKeys) +} + +// Deleted returns a slice of IDs for edges that were not seen during the operation. +func (s *Tracker) Deleted() []uint64 { + s.seenKeysLock.RLock() + defer s.seenKeysLock.RUnlock() + + deletedEdges := make([]uint64, 0, len(s.entities)-len(s.seenKeys)) + + for _, edge := range s.entities { + if _, seen := s.seenKeys[edge.Key]; !seen { + deletedEdges = append(deletedEdges, edge.ID) + } + } + + return deletedEdges +} + +// HasEdge checks whether a specific edge exists in the tracker. If found, it marks the key as seen. +func (s *Tracker) HasEdge(start, end uint64, edgeKind graph.Kind) bool { + var ( + edgeKey = s.encoderPool.EdgeKey(start, end, edgeKind) + _, found = slices.BinarySearchFunc(s.entities, edgeKey, func(e trackedEntity, t uint64) int { + if e.Key < t { + return -1 + } + + if e.Key > t { + return 1 + } + + return 0 + }) + ) + + if found { + s.seenKeysLock.Lock() + s.seenKeys[edgeKey] = struct{}{} + s.seenKeysLock.Unlock() + } + + return found +} + +// EdgeTrackerBuilder builds a Tracker from a sequence of tracked edges. +type EdgeTrackerBuilder struct { + edges []trackedEntity + encoderPool *KeyEncoderPool +} + +// NewTrackerBuilder creates a new builder for constructing a Tracker. +func NewTrackerBuilder() *EdgeTrackerBuilder { + return &EdgeTrackerBuilder{ + encoderPool: NewEdgeEncoderPool(), + } +} + +// TrackEdge adds an edge to the builder for later tracking. +func (s *EdgeTrackerBuilder) TrackEdge(edge, start, end uint64, kind graph.Kind) { + s.edges = append(s.edges, trackedEntity{ + ID: edge, + Key: s.encoderPool.EdgeKey(start, end, kind), + }) +} + +// Build constructs a Tracker from the accumulated edges. +func (s *EdgeTrackerBuilder) Build() *Tracker { + // Sort the edges before building the tracker + slices.SortStableFunc(s.edges, func(a, b trackedEntity) int { + if a.Key < b.Key { + return -1 + } + + if a.Key > b.Key { + return 1 + } + + return 0 + }) + + return &Tracker{ + entities: s.edges, + encoderPool: s.encoderPool, + + // Assume that the tracker will have a high hit ratio. This may be better exposed as a function parameter + // but for now this seems like a safe bet. + seenKeys: make(map[uint64]struct{}, len(s.edges)), + } +} + +// FetchTracker retrieves all relevant edges that match one of the edge kinds given from the database. It uses +// this data to then build a Tracker. +func FetchTracker(ctx context.Context, db graph.Database, edgeKinds graph.Kinds) (*Tracker, error) { + var ( + tracef = trace.Function(ctx, "FetchTracker") + builder = NewTrackerBuilder() + numResults = uint64(0) + ) + + if err := db.ReadTransaction(ctx, func(tx graph.Transaction) error { + return tx.Relationships().Filter(query.And( + query.Not(query.KindIn(query.Start(), graph.StringKind("Meta"), graph.StringKind("MetaDetail"))), + query.KindIn(query.Relationship(), edgeKinds...), + query.Not(query.KindIn(query.End(), graph.StringKind("Meta"), graph.StringKind("MetaDetail"))), + )).Query( + func(results graph.Result) error { + var ( + edgeID graph.ID + startID graph.ID + edgeKind graph.Kind + endID graph.ID + ) + + for results.Next() { + if err := results.Scan(&edgeID, &startID, &edgeKind, &endID); err != nil { + return err + } + + builder.TrackEdge(edgeID.Uint64(), startID.Uint64(), endID.Uint64(), edgeKind) + numResults += 1 + } + + results.Close() + return results.Error() + }, + query.Returning( + query.RelationshipID(), + query.StartID(), + query.KindsOf(query.Relationship()), + query.EndID(), + ), + ) + }); err != nil { + return nil, err + } + + tracef(slog.Uint64("num_edges_tracked", numResults)) + return builder.Build(), nil +} diff --git a/packages/go/analysis/delta/tracker_test.go b/packages/go/analysis/delta/tracker_test.go new file mode 100644 index 00000000000..3276d32af40 --- /dev/null +++ b/packages/go/analysis/delta/tracker_test.go @@ -0,0 +1,208 @@ +// 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 delta + +import ( + "sync" + "testing" + + "github.com/specterops/dawgs/graph" + "github.com/stretchr/testify/require" +) + +var ( + kindA = graph.StringKind("A") + kindB = graph.StringKind("B") + kindC = graph.StringKind("C") +) + +func TestKeyEncoder_Key_Deterministic(t *testing.T) { + enc := NewKeyEncoder() + + start, end := uint64(12345), uint64(67890) + + key1 := enc.EdgeKey(start, end, kindA) + key2 := enc.EdgeKey(start, end, kindA) + + if key1 != key2 { + t.Fatalf("KeyEncoder.Key should be deterministic, got %d and %d", key1, key2) + } +} + +func TestKeyEncoder_Key_VaryingInputs(t *testing.T) { + enc := NewKeyEncoder() + start, end := uint64(1), uint64(2) + + keys := make(map[uint64]struct{}) + keys[enc.EdgeKey(start, end, kindA)] = struct{}{} + keys[enc.EdgeKey(start+10, end, kindA)] = struct{}{} + keys[enc.EdgeKey(start, end, kindB)] = struct{}{} + keys[enc.EdgeKey(end, start, kindA)] = struct{}{} + + if len(keys) != 4 { + t.Fatalf("Expected 4 distinct keys for different inputs, got %d {%v+}", len(keys), keys) + } + + _, exists := keys[enc.EdgeKey(start, end, kindA)] + require.True(t, exists) + + _, exists = keys[enc.EdgeKey(start+10, end, kindA)] + require.True(t, exists) + + _, exists = keys[enc.EdgeKey(start, end, kindB)] + require.True(t, exists) + + _, exists = keys[enc.EdgeKey(end, start, kindA)] + require.True(t, exists) +} + +// Helper to compute a key without using the internal encoder (for expected ordering checks) +func computeKey(start, end uint64, k graph.Kind) uint64 { + enc := NewKeyEncoder() + return enc.EdgeKey(start, end, k) +} + +func TestTrackerBuilder_Build_SortsKeysAndIDs(t *testing.T) { + builder := NewTrackerBuilder() + + // Insert edges in unsorted order. + builder.TrackEdge(100, 5, 10, kindA) // edgeID 100 + builder.TrackEdge(101, 2, 8, kindB) // edgeID 101 + builder.TrackEdge(102, 7, 3, kindC) // edgeID 102 + + sub := builder.Build() + + // Verify that edgeKeys are sorted ascending. + for i := 1; i < len(sub.entities); i++ { + if sub.entities[i-1].Key > sub.entities[i].Key { + t.Fatalf("edgeKeys not sorted: %v", sub.entities) + } + } + + // Verify that edgeIDs have been reordered to match the sorted keys. + // Compute the keys manually to know the expected order. + keys := []uint64{ + computeKey(5, 10, kindA), // edge 100 + computeKey(2, 8, kindB), // edge 101 + computeKey(7, 3, kindC), // edge 102 + } + + // Sort the keys to get the expected order. + sortedIdx := make([]int, len(keys)) + for i := range sortedIdx { + sortedIdx[i] = i + } + + // Simple bubble sort on indices based on keys (just for test readability) + for i := range len(keys) { + for j := i + 1; j < len(keys); j++ { + if keys[sortedIdx[i]] > keys[sortedIdx[j]] { + sortedIdx[i], sortedIdx[j] = sortedIdx[j], sortedIdx[i] + } + } + } + expectedOrder := []uint64{ + []uint64{100, 101, 102}[sortedIdx[0]], + []uint64{100, 101, 102}[sortedIdx[1]], + []uint64{100, 101, 102}[sortedIdx[2]], + } + + if len(sub.entities) != len(expectedOrder) { + t.Fatalf("unexpected number of edges") + } + + for i, edge := range sub.entities { + if edge.ID != expectedOrder[i] { + t.Fatalf("edge ID at index %d expected %d, got %d", i, expectedOrder[i], edge.ID) + } + } +} + +func TestTracker_HasEdge_And_DeletedEdges(t *testing.T) { + builder := NewTrackerBuilder() + + // Edge set we will actually add to the subgraph. + builder.TrackEdge(10, 1, 2, kindA) // edgeID 10 + builder.TrackEdge(20, 3, 4, kindB) // edgeID 20 + builder.TrackEdge(30, 5, 6, kindC) // edgeID 30 + + sub := builder.Build() + + // Query a subset of edges – deliberately omit the second one. + if !sub.HasEdge(1, 2, kindA) { + t.Fatalf("expected edge (1,2,kindA) to be present") + } + if sub.HasEdge(3, 5, kindB) { + t.Fatalf("expected edge (3,5,kindB) to be *absent* from HasEdge calls") + } + if !sub.HasEdge(5, 6, kindC) { + t.Fatalf("expected edge (5,6,kindC) to be present") + } + + // DeletedEdges should now contain only the ID of the edge we never queried. + deleted := sub.Deleted() + if len(deleted) != 1 { + t.Fatalf("expected exactly one deleted edge, got %d", len(deleted)) + } + if deleted[0] != 20 { + t.Fatalf("expected deleted edge ID to be 20, got %d", deleted[0]) + } +} + +func TestTracker_HasEdge_DuplicateCalls(t *testing.T) { + builder := NewTrackerBuilder() + builder.TrackEdge(55, 11, 22, kindA) + sub := builder.Build() + + // Call HasEdge many times – it should stay true and not affect DeletedEdges. + for i := range 5 { + if !sub.HasEdge(11, 22, kindA) { + t.Fatalf("edge should be found on iteration %d", i) + } + } + + if got := sub.Deleted(); len(got) != 0 { + t.Fatalf("expected no deleted edges after repeated HasEdge calls, got %v", got) + } +} + +func TestTracker_ConcurrentHasEdge(t *testing.T) { + builder := NewTrackerBuilder() + // Build a larger set of edges (10 edges) + for i := range 10 { + builder.TrackEdge(uint64(100+i), uint64(i), uint64(i+100), kindA) + } + sub := builder.Build() + + var wg sync.WaitGroup + query := func(startIdx, endIdx int) { + defer wg.Done() + for i := startIdx; i < endIdx; i++ { + if ok := sub.HasEdge(uint64(i), uint64(i+100), kindA); !ok { + t.Errorf("expected edge %d to exist", i) + } + } + } + + wg.Add(2) + go query(0, 5) // first half + go query(5, 10) // second half + wg.Wait() + + if del := sub.Deleted(); len(del) != 0 { + t.Fatalf("expected no deleted edges after concurrent queries, got %v", del) + } +} diff --git a/packages/go/analysis/hybrid/hybrid.go b/packages/go/analysis/hybrid/hybrid.go index db4844aaecb..589f862a611 100644 --- a/packages/go/analysis/hybrid/hybrid.go +++ b/packages/go/analysis/hybrid/hybrid.go @@ -24,6 +24,7 @@ import ( "github.com/specterops/bloodhound/packages/go/analysis" "github.com/specterops/bloodhound/packages/go/analysis/azure" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/measure" adSchema "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -35,7 +36,7 @@ import ( "github.com/specterops/dawgs/util/channels" ) -func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostProcessingStats, error) { +func PostHybrid(ctx context.Context, db graph.Database) (*post.AtomicPostProcessingStats, error) { defer measure.ContextLogAndMeasure( ctx, slog.LevelInfo, @@ -48,7 +49,7 @@ func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostPro // Fetch all Azure tenants first tenants, err := azure.FetchTenants(ctx, db) if err != nil { - return &analysis.AtomicPostProcessingStats{}, fmt.Errorf("fetching Entra tenants: %w", err) + return &post.AtomicPostProcessingStats{}, fmt.Errorf("fetching Entra tenants: %w", err) } // Spin up a new parallel operation to speed up processing @@ -115,7 +116,7 @@ func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostPro } } - if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- analysis.CreatePostRelationshipJob) error { + if err := operation.Operation.SubmitReader(func(ctx context.Context, tx graph.Transaction, outC chan<- post.EnsureRelationshipJob) error { for azUser, potentialADUser := range entraToADMap { var adUser = potentialADUser @@ -129,7 +130,7 @@ func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostPro } } - SyncedToEntraUserRelationship := analysis.CreatePostRelationshipJob{ + SyncedToEntraUserRelationship := post.EnsureRelationshipJob{ FromID: adUser, ToID: azUser, Kind: adSchema.SyncedToEntraUser, @@ -139,7 +140,7 @@ func PostHybrid(ctx context.Context, db graph.Database) (*analysis.AtomicPostPro return nil } - SyncedToADUserRelationship := analysis.CreatePostRelationshipJob{ + SyncedToADUserRelationship := post.EnsureRelationshipJob{ FromID: azUser, ToID: adUser, Kind: azureSchema.SyncedToADUser, diff --git a/packages/go/analysis/impact/aggregator.go b/packages/go/analysis/impact/aggregator.go deleted file mode 100644 index 6b2cd4590cd..00000000000 --- a/packages/go/analysis/impact/aggregator.go +++ /dev/null @@ -1,260 +0,0 @@ -// Copyright 2023 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 impact - -import ( - "fmt" - "log/slog" - "sync" - - "github.com/specterops/bloodhound/packages/go/bhlog/measure" - "github.com/specterops/dawgs/cardinality" - "github.com/specterops/dawgs/graph" -) - -// PathAggregator is a cardinality aggregator for paths and shortcut paths. -// -// When encoding shortcut paths the aggregator will track node dependencies for nodes that otherwise would have missing -// cardinality entries. Dependencies are organized as an adjacency list for each node. These adjacency lists combine to -// make a dependency graph of cardinalities that can be traversed. -// -// Once all paths are encoded, shortcut or otherwise, into the aggregator, users may then resolve the full cardinality -// of nodes by calling the cardinality functions of the aggregator. Resolution is accomplished using a recursive -// depth-first strategy. -type PathAggregator interface { - Cardinality(targets ...uint64) cardinality.Provider[uint64] - AddPath(path *graph.PathSegment) - AddShortcut(path *graph.PathSegment) -} - -type ThreadSafeAggregator struct { - aggregator PathAggregator - lock *sync.RWMutex -} - -func (s ThreadSafeAggregator) Cardinality(targets ...uint64) cardinality.Provider[uint64] { - s.lock.Lock() - defer s.lock.Unlock() - - return s.aggregator.Cardinality(targets...) -} - -func (s ThreadSafeAggregator) AddPath(path *graph.PathSegment) { - s.lock.Lock() - defer s.lock.Unlock() - - s.aggregator.AddPath(path) -} - -func (s ThreadSafeAggregator) AddShortcut(path *graph.PathSegment) { - s.lock.Lock() - defer s.lock.Unlock() - - s.aggregator.AddShortcut(path) -} - -func NewThreadSafeAggregator(aggregator PathAggregator) PathAggregator { - return &ThreadSafeAggregator{ - aggregator: aggregator, - lock: &sync.RWMutex{}, - } -} - -type aggregator struct { - resolved cardinality.Duplex[uint64] - cardinalities *graph.IndexedSlice[uint64, cardinality.Provider[uint64]] - dependencies map[uint64]cardinality.Duplex[uint64] - newCardinalityProvider cardinality.ProviderConstructor[uint64] -} - -func NewAggregator(newCardinalityProvider cardinality.ProviderConstructor[uint64]) PathAggregator { - return aggregator{ - cardinalities: graph.NewIndexedSlice[uint64, cardinality.Provider[uint64]](), - dependencies: map[uint64]cardinality.Duplex[uint64]{}, - resolved: cardinality.NewBitmap64(), - newCardinalityProvider: newCardinalityProvider, - } -} - -// pushDependency adds a new dependency for the given target. -func (s aggregator) pushDependency(target, dependency uint64) { - if dependencies, hasDependencies := s.dependencies[target]; hasDependencies { - dependencies.Add(dependency) - } else { - newDependencies := cardinality.NewBitmap64() - newDependencies.Add(dependency) - - s.dependencies[target] = newDependencies - } -} - -// popDependencies will take the simplex cardinality provider reference for the given target, remove it from the -// containing map in the aggregator and then return it -func (s aggregator) popDependencies(targetID uint64) []uint64 { - dependencies, hasDependencies := s.dependencies[targetID] - delete(s.dependencies, targetID) - - if hasDependencies { - return dependencies.Slice() - } - - return nil -} - -func (s aggregator) getImpact(targetID uint64) cardinality.Provider[uint64] { - return s.cardinalities.GetOr(targetID, s.newCardinalityProvider) -} - -// resolution is a cursor type that tracks the resolution of a node's impact -type resolution struct { - // target is the uint64 ID of the node being resolved - target uint64 - - // impact stores the cardinality of the target's impact - impact cardinality.Provider[uint64] - - // completions are cardinality providers that will have this resolution's impact merged into them - completions []cardinality.Provider[uint64] - - // dependencies contains a slice of uint64 node IDs that this resolution depends on - dependencies []uint64 -} - -// resolve takes the target uint64 ID of a node and calculates the cardinality of nodes that have a path that traverse -// it -func (s aggregator) resolve(targetID uint64) cardinality.Provider[uint64] { - var ( - targetImpact = s.getImpact(targetID) - resolutions = map[uint64]*resolution{ - targetID: { - target: targetID, - impact: targetImpact, - dependencies: s.popDependencies(targetID), - }, - } - stack = []uint64{targetID} - ) - - for len(stack) > 0 { - // Pick up the next resolution - next := resolutions[stack[len(stack)-1]] - - // Exhaust the resolution's dependencies - if len(next.dependencies) > 0 { - nextDependency := next.dependencies[len(next.dependencies)-1] - next.dependencies = next.dependencies[:len(next.dependencies)-1] - - if s.resolved.Contains(nextDependency) { - // If this dependency has already been resolved, fetch and or it with this resolution's pathMembers - next.impact.Or(s.cardinalities.Get(nextDependency)) - } else if inProgressResolution, hasResolution := resolutions[nextDependency]; hasResolution { - // If this dependency is in the process of being resolved; track this node (var next) as a completion - // to or with the in progress resolutions pathMembers once fully resolved - inProgressResolution.completions = append(inProgressResolution.completions, next.impact) - } else { - // For each dependency not already resolved or in-progress is descended into as a new resolution - stack = append(stack, nextDependency) - resolutions[nextDependency] = &resolution{ - target: nextDependency, - impact: s.getImpact(nextDependency), - completions: []cardinality.Provider[uint64]{next.impact}, - dependencies: s.popDependencies(nextDependency), - } - } - } else { - // Pop the resolution from our dependency unwind - stack = stack[:len(stack)-1] - } - } - - // First resolution pass for completion dependencies - for _, nextResolution := range resolutions { - for _, nextCompletion := range nextResolution.completions { - nextCompletion.Or(nextResolution.impact) - } - } - - // Second resolution pass for completion dependencies that were not fully resolved on the first pass - for _, nextResolution := range resolutions { - for _, nextCompletion := range nextResolution.completions { - nextCompletion.Or(nextResolution.impact) - } - - s.resolved.Add(nextResolution.target) - } - - return targetImpact -} - -func (s aggregator) Cardinality(targets ...uint64) cardinality.Provider[uint64] { - slog.Debug(fmt.Sprintf("Calculating pathMembers cardinality for %d targets", len(targets))) - defer measure.MeasureWithThreshold(slog.LevelDebug, "Calculated pathMembers cardinality", slog.Int("num_targets", len(targets)))() - - impact := s.newCardinalityProvider() - - for _, target := range targets { - if s.resolved.Contains(target) { - impact.Or(s.cardinalities.Get(target)) - } else { - impact.Or(s.resolve(target)) - } - } - - return impact -} - -func (s aggregator) AddPath(path *graph.PathSegment) { - impactingNodes := []uint64{ - path.Node.ID.Uint64(), - } - - for cursor := path.Trunk; cursor != nil; cursor = cursor.Trunk { - // Only pull the pathMembers from the map if we have nodes that should be counted for this cursor - if len(impactingNodes) > 0 { - s.getImpact(cursor.Node.ID.Uint64()).Add(impactingNodes...) - } - - impactingNodes = append(impactingNodes, cursor.Node.ID.Uint64()) - } -} - -func (s aggregator) AddShortcut(path *graph.PathSegment) { - var ( - terminalUint32ID = path.Node.ID.Uint64() - impactingNodes = []uint64{ - terminalUint32ID, - } - ) - - for cursor := path.Trunk; cursor != nil; cursor = cursor.Trunk { - cursorNodeUint32ID := cursor.Node.ID.Uint64() - - // Add the terminal shortcut as a dependency to each ascending node - s.pushDependency(cursorNodeUint32ID, terminalUint32ID) - - // Only pull the pathMembers from the map if we have nodes that should be counted for this cursor - if len(impactingNodes) > 0 { - s.getImpact(cursorNodeUint32ID).Add(impactingNodes...) - } - - impactingNodes = append(impactingNodes, cursor.Node.ID.Uint64()) - } -} - -func (s aggregator) Resolved() cardinality.Duplex[uint64] { - return s.resolved -} diff --git a/packages/go/analysis/impact/aggregator_test.go b/packages/go/analysis/impact/aggregator_test.go deleted file mode 100644 index 286c4960f8d..00000000000 --- a/packages/go/analysis/impact/aggregator_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright 2023 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 impact_test - -import ( - "testing" - - "github.com/specterops/bloodhound/packages/go/analysis/impact" - "github.com/specterops/dawgs/cardinality" - "github.com/specterops/dawgs/graph" - "github.com/stretchr/testify/require" -) - -var ( - aKind = graph.StringKind("A") - edgeKind = graph.StringKind("EDGE") - nextID = graph.ID(0) -) - -func resetNextID() { - nextID = 0 -} - -func getNextID() graph.ID { - id := nextID - nextID++ - - return id -} - -func descend(trunk *graph.PathSegment, nextNode *graph.Node) *graph.PathSegment { - return trunk.Descend(nextNode, rel(nextNode, trunk.Node)) -} - -func rel(start, end *graph.Node) *graph.Relationship { - return graph.NewRelationship(getNextID(), start.ID, end.ID, nil, edgeKind) -} - -func node(nodeKinds ...graph.Kind) *graph.Node { - return graph.NewNode(getNextID(), nil, nodeKinds...) -} - -func requireImpact(t *testing.T, agg impact.PathAggregator, nodeID uint64, containedNodes ...uint64) { - nodeImpact := agg.Cardinality(nodeID).(cardinality.Duplex[uint64]) - - if int(nodeImpact.Cardinality()) != len(containedNodes) { - t.Fatalf("Expected node %d to contain %d impacting nodes but saw %d: %v", int(nodeID), len(containedNodes), int(nodeImpact.Cardinality()), nodeImpact.Slice()) - } - - for _, containedNode := range containedNodes { - require.Truef(t, nodeImpact.Contains(containedNode), "Expected node %d to contain node %d. Impact for node 0: %v", int(nodeID), int(containedNode), nodeImpact.Slice()) - } -} - -func TestAggregator_Impact(t *testing.T) { - resetNextID() - - var ( - node0 = node(aKind) - node1 = node(aKind) - node2 = node(aKind) - node3 = node(aKind) - node4 = node(aKind) - node5 = node(aKind) - node6 = node(aKind) - node7 = node(aKind) - node8 = node(aKind) - node9 = node(aKind) - node10 = node(aKind) - node11 = node(aKind) - - rootSegment = graph.NewRootPathSegment(node0) - - node1Segment = descend(rootSegment, node1) - node3Segment = descend(node1Segment, node3) - node5Segment = descend(node3Segment, node5) - node8Segment = descend(node5Segment, node8) - node8to10Shortcut = descend(node8Segment, node10) - - node6Segment = descend(node3Segment, node6) - node6to7Shortcut = descend(node6Segment, node7) - - node11Segment = descend(rootSegment, node11) - node11to4Shortcut = descend(node11Segment, node4) - - node2Segment = descend(rootSegment, node2) - node4Segment = descend(node2Segment, node4) - node7Segment = descend(node4Segment, node7) - node9Segment = descend(node7Segment, node9) - - node2to3Shortcut = descend(node2Segment, node3) - node7to3Shortcut = descend(node7Segment, node3) - - // Node 10 is Terminal for the node9 and node11 segments - node9to10Terminal = descend(node9Segment, node10) - node11to10Terminal = descend(node11Segment, node10) - - // Make sure to use an exact cardinality container (bitset in this case) - agg = impact.NewAggregator(func() cardinality.Provider[uint64] { - return cardinality.NewBitmap64() - }) - ) - - agg.AddPath(node9to10Terminal) - agg.AddPath(node11to10Terminal) - - agg.AddShortcut(node2to3Shortcut) - agg.AddShortcut(node11to4Shortcut) - agg.AddShortcut(node6to7Shortcut) - agg.AddShortcut(node7to3Shortcut) - agg.AddShortcut(node8to10Shortcut) - - // Validate node 2 impact values and resolutions - requireImpact(t, agg, 2, 3, 4, 5, 6, 7, 8, 9, 10) - - // Validate node 1 impact values and resolutions - requireImpact(t, agg, 1, 3, 5, 6, 7, 8, 9, 10) - - // Validate node 11 impact values and resolutions - requireImpact(t, agg, 11, 3, 4, 5, 6, 7, 8, 9, 10) - - // Validate cached resolutions are correct for node 2 - requireImpact(t, agg, 2, 3, 4, 5, 6, 7, 8, 9, 10) -} diff --git a/packages/go/analysis/impact/aggregator_test_diagram.png b/packages/go/analysis/impact/aggregator_test_diagram.png deleted file mode 100644 index c71e8d9ced555bfb746ec964485c9d4328b924fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 209989 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

xSdmnwiC! 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%0A)lO>5REg<(Z5H}%O zw<1|S^?QNN0t z#EC8?%lt(&-IhOCp~tpIkR&lLdTt3hIfSw_#}!z#tfr&G4>n%+A0RWNdsiHD_V!<6 z=l+;s6{t))jP(^>x3<1Aks}f~#Rt{3gq!D)BNKQMFqDC&-e;z)XUEa}dCHVLV6eQc z#&qv8OMtLBmfFkbwKO-I)gQ{`CmeW8xZZnzWVLg1h1d=~B(^w|`53=NfqPydB?4hh zamafwKSN-$X87p2FT?)6>mboBF>LbR+mx(XvKPRg0|;+h=9@S7i;UIE5`l5IV;DpJ z3j|Cp{($bs@a7Jh(#P8^K!Q^Rv2kW&lV&46YEo>h@j8CWYu5h@ead3qQNIJ4-93Bu2mw&* z6nU^hOXLLUSLALlp(m}1Wm2A&47xUKbQIJxHN2H_WX9o4a1)ku* z{xHBMLJZl#zPm$2W83TYc34_+@q^ql*+>`27tTIh$K(1UKAet(c_$vmcId<=R&nSliJQynuNdO>H=P+*l>Y?~A0cAX1unH09V12={e9w*Y#Tq#!z7ZH>W@I!$ z06WqJc@sadxq3*|ArS^V6|{?9yqOd|A*2+M%#FDWkyreEc+&r6#mf!7cT=D1>-${M zx-oQ(KFsRu86LR8lTe_(mJT97g$q>9huiZt{(jSn3Ptfj zV)(-Y%PCZ>Sh>`qz%TCrCOt~^%Kb+uU4aKHY=Rv(KGx^J1&8(?s^O2&kp7tFj+}QT zXH!sTVR=17xQ?;#+!6T%uoXS*HFSAW;Az3}l@z--Xi}<^J2FkFTQI1kjXjk|ZVftT zz>yCXPmRKts)aT%g+RgbN;BQSf0bx}MzIcDRVfxDHz2CzJj(JmL^1rVY2Sj|i+w^S ziED=EprUMM0HuiJPgbsvj0e6doza*S`j_fEWw*tglF#=x)6^=LpRCQJ4x@WS00GIB z0#+HD?bC5;2FawIiM|FxR#cId9RI?Zhbm=h^?I@&ogS+?4=QBSm)l{RWY;tOtdxTN zE8gK7&4A9}O!^!Hc!!c$!ByqKv5_Bmm`zXLp3x$|-8<5HZ2zrVzAdoGegFqY%$xKH z=h?Qcdj~J#n6bAs0|J|Cdxn~pRS%FgiKSS;l?3@{+ouZaYRvG+*aVbfqrnMdC-Xg& z2zi+uGh%OoI=YqGCoSK(hW48^Ax0lkJs3O=7UnN#cf%82e9$|Uwr`L5XyZ3i21M#? zeC9m4hUlnw!@>$GXAqcyKS%a{pUp}A60hYbZIV!$YjE(7f$Xj?G+Zzz6HduEU1p6f zMTJNcN*alsTk$2C-E7E5m~iYYpLuTwTTxEs z$PbVsHU-@@PQ)N%21=TGpot`T@{$xL&j3yZT3&<#uxx9I50Xf;nj20 zt5^niz*x3x*T26y<|Cv7)b;L9w(B#pientc7uSv)2?6|h|EIZHPTwX@&MaVR=5l&* zPz~Xl*pyRILm(|-TVM+SJCNzm2QuntY!B zNC>);O7$LobCi%WK;*+675afvz}LU9`mmkp`2Q^*>_Lb@0kMuB(-N3S-v4-L&HW+3 zAhsy%Fz520)csqj?b`-5hm?e+ReHCyv|@xOO;<~~$)EmyO@X&2US+Y%3{{Uv7R2bN zsl`;q^RN4-DG=$=AC>k?KD6;(8sx$2Qp@>#DAn@sC>o}O+B3BIA zL9fy6O3ZSl9`L&T+m(~)8kg*;Zkme)THL*y$6CoP^6pP!^DS zo3e3XmnS)Oh>GtXJ_k68|3<(~`||2~sMV=civd zO@PIZpBu-N=*e>_=9jTj4WrMfy8-}c|LqLX32nq1p98#68g&%HK^32Q3`rJc7)J~1 z{lExtFX{CMtk$A|rsku|{{pL8>^w?a(+&19cqX3ABc9n&ef3dp@Gvscazu29*yvACxV1BmLcaBuXzc~ITl&0p?+%vE> zlMYu>pWf6JqHdXxh_$`Wz8vU%$w@Zw@YAV+r+{oQlcWH>8eQUNCi_w<67QgM1=Z2M zR9RGX0$MuM=j-UUP$-lg;VF2NIO(f7{VyiSW=1HogUI3KjOS{rAz%mk&r7X`0X ztq0pJ*irVTz;*UYwVwD&z=x%;zSS8S8I;J1s*`7+h2yrEOcHKfmWO-7Dd{uV775)D zwnrHo8>_1a&L?ArVq}j9v18gjI3V!m{<{MMN0w+bwWd4wF7Mm1o`oS;qp;`P=BzGZ zJzwbCFap$TudA!;==cC4)?Lg^+DUd3=jl>flWyH=v@yd2T1v(;FXc;HDZE3BLFjz;9glYE<>B`Em^2Cp9R8G76z*<7+UHK^f8?U zFI*a&3LQTz*?&ifrk}w8qVhrB>vqd1doq+hxCl&uJsuK;iWkC$sm|PXXfgtMzb5^)AnygQ@(XtAS2ylnLKoY2bqdA&BETwO zzX$j!QP2yZ5O>5y#Q!_pVrgW=_Vaw+?bP};Y;2?o87iXq=A>|L@?F)=YAX}e}S^$?ts zUJO0M6BiYGj(>B%qk_{WSfJ3*&`cjzgGm-z83=LFtDdzwW=6~k9GNw@Ie4IHMY_>m zC~l!gH-<~ijg_&i>>lwM>!DC{V~g6uwyKHHKVpnlK!;YoZ=9RGs|wRnhDhjaVQ0HUE-*t0wukHRfFGO{D|ymlOEk$iQ% zjOJO5<+j~l_M;Wq{Jx-O#06mjFXSYiG@hDR`%htU@lR+21CZjr0AhLlT33MmJ%TCj zAL*9{c>d_<>A_%jwMtZvB}TUtZ|%>;zr>IL>~Ha)a#?TiwJ)Hj|AUbwbOP6rFkl*f zRxVRS36DYb+dy4C2v(@xp6+6E2^Q8P2O1#fBBxkDUZ4|k+INqho}P96j$v}AXiN+V zyq|qG2d64166%=>QI%mN0!A)xL^L)~9n0~2H}1Jet`&-gRy4Hv1LEz)&yOcij9qM3 z{dOfZ6nGRNZ1C7%EPtJ@_{xuAmT&IY-@E>J+I5z2em&xXLGs@aDna|uhhePiqHzZI zpliV&J%XW&&v*#18gi=S>ZO0Fv?+{JH_qIyXDvz#MTf5N#o=2pokVT$Xu~Fk#LxX- z@x|FU$c|z_0quM8#11H~;O~Xt_Pzbm?{#q0ZrdE0^?KYFgO|`*jgl@LU^bhZ-Jjht z%3b*O4a^NW7T)Y3HV1Qu1Z{)xGcXN4^ysBFtaiyo<9nbpK=2U0XWgK+Z(q4X_ovIN zH$ge-gJ^(Qa^_4PAnGrqu?G2*(c9oxB!5uy%TU32fO`Ggw}yHDilmH;=ZJGC?S@nW zhK}z**m?x4!QGGX9i-!!twZ{1y@Tm|avec3W|(oxVeh1bcnDE5j12G>R#W3=V@MV_ z%A-MNuV;lYTY}9zK#)DKHS9>pugS^mV9mj z%UOl{i}@D(1*|_}2v<}UwE~E#7(i)hX*2#h8f#&+0Ja$`P5A``rUg3iRlTQ+8+tuN z(fxNJPZ+c6@m;c^<-6}fZ^ zCp;}9qj0$vE4&nT(}8~2&>xI7>~2Z|XkC!CNO={WBqX7jtWD3z_~NfqZS&;|D)3{^ z52!5*3kudKdjLGDlf2z=G}?Glir<6_ah)73LypVuZgT#*xQLRCy z+=(zrM}9-y=nmsA)bAK{Hx0O!)mY*Ws~hrX;#7jpA=fFG$`oRZ9#t{Ce7T{@dA5%0 z#mQo<$smtp^5XtV;8|`@bc&}h{KTLrY2u5vD+6B= zm-?Wl*MMsSuzMx9(bs)t#XEuDy-o^nt9PMZc;a$+%~;(*H#Jr8TJjl#&hN|4@os-k z(RqO!fO)HnNF%)_hr~qfqO!d4ltF9YXG>tP5ywxTA}j0mI(7?6Q*v=*r>efGNuskA zr=8Jo^UobFKZQ+3y>AZ`+Hx3A78uDU#0cx*bfF-+I^Tu7ju=Zy7(_*NQjT5#WX7-T zW{VS_aLpjiNYQm!7{0;$j(%KrYw&%~<$1da(;G&)kozrRqDO)! zN}-Hgz5x&fs_Vq~4ZG4lW7tGuBw$q=d~=oEvG_c1;bJ39NXvYT1lSsIB8MhpviXJFNRJG{$5+LKiE zm$m5=p!Ts5@6Z-MHcaT9ubSHjtFID%$u+f*BcrSuPaStrf`3)k3LCC*sykN``X^8~ zO2-({VB!BL!J@HJ>VsCPr#Lac?&YStrCVVoHb6X>7%FfRDk-+lhbxWyU1OO3Z1ZWp zOJ~JX=0nKG3+rJ75!HI-d`Mo;6p2&7X#i>ihtlTHu;9T&$-5)$ijW${H)b13M%In2 z)i!Yxiex({BXD1fJD+M>&b_%n@Ip$rL+QEmiCTvvo*(uJ&#H4MLq}2i>SP&Jd@|RZ z?yaM&)lB02PYd0LnF;7~fwl81`aA3v}t>>)SMXN9Mwe6p?RKi!&afsxClEGVXs zNMTQK!Ni^Z39KSWWT|g^gDcMOC4c{_D+>ZSSbbLRHl{zeatVW0kv}&XZw@Z|qXyep;QK+WttHkOv1# zE+a}Uyo_B;%>ZM>v+HN3@m)z(T+Dl)^z&f8PM*wC_#&ch{$%6QRH9Q9)mHXai+;}2 zE4v)J&7Vlxn`vBb%=)FlR@#_d{ZL5B(K5c#w)~KjLQ1PWULVg?tLFt?lLGyUI_FaE zOYc93vn0|aIWQ|z3v ztq#{MtL11lu2ov@3RW9>pbSp@F`1N-f)wjL7I1PWA6ca_{rTZ_3Q~7G7#zag0q_sV z*i#r0nN2MozZJp6zukN9=z5)Ltt zJLas^F8F(Re8u4PBRy_$Y3cI_olPsw8f)#_Qmy9csfq`mq)}T$;$PjZE~8jjPAc4L zr57oEB7Rse(HU3d*2A113rE!-iw|0sQct>{IbU61dQO8c&*NNXM*uStJZx0W*evk? ztmPB6^y1LOCFXnfQne=-(-99z=>07i27{X|haWik+2tt)bQZvj2yz?+)kk|J%M&L}W(F$j&BYr^u#^Y${}w5R!~SMz&-N4WrEH zgECsku7re&Qf4$Hl?u;!eedT!o`0T>`*$3_<9FAG&vm`W>vg`)(b?5yi(Hpum-gIj zfJMMax5XErCS-&CjO}F22n3uZ_wyk;hl|6dMVs3U8Qw4IVSWgO#K_h4y^B>3e%c*8 zcyRFC0W9s!a1wjKrILISFE}9JE^B2skGB#6GXf@w;o}OxbE?y=m9o-W$H@sY8HW;D zd+=5W=$?ieKWT_df;nvxk={?|HFm~Y)AYz`e7m9h`cc>;xyuRD%Op79!C0bFK7$h%KL zcI8ScHem~thnDAxj~29=EALoo-)}2f7UO!T_S9R0+T3TKs2WAD7~f?5^r)cK+v!nt zm*<_=LI$iniZyNGhN+_-yK;=XH+*AVJ(oM^ESF;}!WOcj-Mh}li_ySe^wOyswYBX9 zgXV!UH6wm|ENo`)s;x*qlmA@(#-`In@tsYK&X!7!n$pMEgg6>UoGnN7QiUuU%h|<8 z+hB|cc~$qcClb*}W<+UfTmkuu;7zh53Qd3J5@4$JCKb+EWA4s%5E z8!8TAO9P$#PWRpbGqXXN{ZmZ={^%HdfyH!rag#hM9WHq{2K!%`AM3Xs3B2*ingx7Q zgrjcw%LAdqF6St>@xwmgPDa+i-gnY?@bKBuC19M^D`zo#Ezh72MJr~1CBf&0GGoj! z9K+plz1S&MKil>w_4M{$gNr=nRv!dzkenP(BOcepS-%Sq4@e7~w6*OX5Ls6*Yqo{l zYn+^&59cwmc?c-c%Urm|MiO*_Lz!YBAh7r5tKB+~>e}!W8^PnwYtMkDQSTbt10reW zY5`^b3063}9-6t;*-D9|jexfJIiP&dv;TRgTl{$!caEAa=dC(1&x)=*s<`c+5Kx0t zj6)-HIk+uHN~1vN&Xp=ZnV4tJaw_Z(g$+`*u`1;3xpnsJWo#OBk+ZF^0`NiNA5B6A z1*2Aua`uuOci+d2@*4`TEElwS%GnRgc-+fArG6rSUP8@&(>3PuHW#_WRhJVu(#ZAk zIl6anX z({VgUV9FIEf*rn~epj?_Q^2inC{v2!9EYf(rM-Ui2WVO$Q@r#*0j{q2%iDV{<6^(( zHyZ5d_#TbHH;snPU{T?|bVV5BafjODmlX>)fdnm{FOHhtrlppuM7P9$y`Vuq(3}yq z3m@yz8e1PzkyBIqi!#sgQ6hvMxT&m`sLv`n*4bg`wSRhc`E{Z={OR|?E`u_pfX1!< z*eT+hO@EzEve3t*;9q#7>WLExjE_mibv8i-RtE;X)d1$b?ijBvuru6z_m%FcDE_wH1_f@_7)m|B3 zuX*85mBecA+2M+VVG%+q^VHPQIROamiM+0yXL9HIbb5Vi zs~dhu)v1IjR`;qx^c;ad9m3@?_{6*yeW)5Yqd@S-BqhLpYQValm5X1#d>N&j2iy=G&I(FQnKi{dXko72mjopj_O2SplW|1p621+UsVcLO24FLM%IlJ@eGnW3t8 zpqJ$T^|H7F!YEt^_kz-tf?Y=1 z2EbTO0F@3l^Bc5dbq2)y=K63Ja)ihp;3x3+TK^<3We-N$?^DO{EFk`lEfA$`K@*RE zU-qm-3+ll~|Gpc+@FR8xON!Jiz*t0a_HHLd(umbpC~f{+O{@25eUwY04)R(0nc6wZ z$u7lnrUecE%LVW>eA1OV;V+Zn%xi1(Vm2`KGh>V*Ur&Ltx?cCCG?lAqt_A`}nDsM- za$@!Um6bJ_1Fvew7;6g|QJyYD{J9gWJO(qR&8i3eY*5ucd;aBMswm5>zYaUU%HU_= zf6J*tzT9DgT2I*6S3^NLrtePEf3n~(zS!)muE*Fk%FnUD?bnjD_rgb&WPP`=tD@ac z=JI$iZ{3hQQNX+2!N}~CT(5{hKRPvV+Fff#l>f^=K-r%juy3#!9B8!P^MmDESx_G* zZ{Ph1O(E6+8N#}X3}AE*eaE?#yx#)5%FvA%6c!M0Mh$kj(SJNh1CEp^D)IOd6zSq& z-mvskz8B?n49V0ZR}q{JC^^)FCAgsRf0Y>CN%kOM$h{{UC+hXaN)+WNtAM6N@G$^G zz~>G=oMzs>y%C0p@Yq5ki()R~{q7>yFXwYZYb6+EAd-eI4O(kY%+0AmaDlFV8v~8k zk-_-nWDUOw+T6-(NL~P%jV^*bwNEePeU(4=QiQbyCfS*ldXpHne$EzQdVFU1^Ut-F zuVLUhrMQkb${M5F0FhfmW5{IKB2Dc}JvlN$gZE#$ES5TeG_70<4~pc7r&U6m2aeW& zrke-F69QsHS@ecNS}_bvH5O7Ew&NlX`7DDB(`>5`f?F?q+)R@%sXAX^Jn_$mVk{I_ zFu=4$lfdbyb z__G}>gg~*3? zKLq1&T#c(7B17Csm%uGs$anq66U>O=YA40qq*y?GcU`(zw=V0#OzKiX0vcN(7KoL= zgjANfyIwdOE(};jar#w55XPT_=2rbH>))p!Q2{MEIb6-pk`G68pa#D)Hxll7XSd$? z!r#w+`vKpZw4!uI%84db`p1?Pc6ISmcOgAMkQ`vjDFHPz@Kym4K!D1_g1GRzfUgzL z7PySK)pg~)pLBKczB2HXi-G1~rf0xHyB9ah!o`*l2Y1un@}?#7J)Tj$%Et3EH=!=# zVAUVrXr@j&s-5u#l5uh@y}nO;)h~;7QG~kl9)B8d8xPFal<7RXEc{%r4WH{sP~ z?pp&)o;Kj&3AZVFklBAqz?m%ortc-4%+VjY%+$gXghAhKaQ zVvD$WIEKZFN=a1$J3^)MO5_5Y*%p-9+qZ7j)5g#Sj%TpWzN}dK$A|kYXhE=2$T)Lc z1?bRR-i7ty6B84V0E{@_VeSJP6MlFOpND=aBVzX2x$j+wZH*O$As+BsK*Z!n+h#rj zw;kuSQL2l=b^vMct}@xGwysnaD~N=BaL{U#6miHl0{+nw`?R&WXSaVdd!xL+riH8M zoY?v5yd8Q`y_J!=I~zam1s97cM~Ij!J>D@<8sgL26uH z_pk3u8H$^HBa){_EatdsC_zWw4?FK@Ynl|?s&{MXorTTxaKEsUw{HtQ)y@P)vx1gJ z#ajcusY3CLannZ9{_@d}I64-%yOPcSWn!19cce*XmN^_@IO5p$%YIEDkIzR4f!>+I zW*?L_N2Da%b}$XLL~H1k9k#Qx1BeXjYq#%PfRpIeC(T{GMIAK+3~Mm z9|0vmLCZ>-J;DbjZchv=D6zQ3edmo%!`>aIr*HGA<&u&TT(uOM3{vloX@RfaE#iI7 z|CizEpy%c`1-Q`zw5b_ruyQoc2iH}x2GoFSNPb4^CuoB!0VlYQ#yKbWi5?wD-T%O+ z{S0^20R8>rj~*87uKK9rH|*7|wT&Va_o?4gY)1nv_o)~3l#hRU1q43)P}R)jzRNXv z)b{?76I04py}l349lL*B(7=C7gMXal8=eBc>*7WQ&!iXbKX!|mqdr(`U$w^XcSqvV z*0pJR)eHJ}M|hrJw%@_gLkT?gjKwy!sD--0sC{-r^66pE=$?R{)MwJ)@5?3Se~gt9 zW0ao0`K$O=s_Qbo2}Zg?1>hsNLqPbNA**@uA0{5dLyv(aZbbmXvv9*>H6w5;42A`i z0JKeWKsKi%Ztc&8&mWFEF=W%n!eRUmxC4cU_sek|X9R*OHD* zufWU_l?e`Qo?0#jT^s*P4OcpqK5oc&8(}&4g6oIsh?B&>Add3= zBi1fycjB7*axb%ONG{3txFe}LT;+1drb1C|=gj7dEmz{ME6q@k9r?Y#LQ(Hd%|iUj zq6&tOK9mw|i?o4+ov%msRB$9%rYjdN=&tp$&TOXPF^T?F`#Nup14QUELUFi%ogd5& zUic&&DQT37Cvfg(6wC#ufeOB67^-pY;V3q1MWtHjRKg8N#O-twU{92uXT64(!0CxN zAj?2ckD@0}aekq~iW~`#tQF!Q^=Q4_G43Ne)CWgmaf~x^*I7OfdiD@S>B-jj4?|a% z-2>W6;7UwZz4O7SJz;lqKwGzP0h9m!i<+4`jk6!G$|#+DC8?xj%@wdKRR{xz=m&I# z1qG1j)NLaO=ZD#fFMSVAE9)hno>1;c$oc5HOD~tkv~HNXPWvUWo$U)NBxw@Pnxv0_ z8a!eCgC@ljC{q&J3mj)3=+swuFE1MENiO&rGL&t(W_zy4=AQ4?n?--ww_-Z+`kq;F zS5FT;6O;Set>Knmyo{$C<6?odDw4A5tMcv#EiBSizr;huzB}-nqhcs+bR+g<)Z_Exals^0gjA>tK zC9oa18m3OQPFYW`gj7GYBuOq#FGoPICqsA4wmi6hANJQpdDLfoF&)4)NtgQsAafo@ z&)A?A58>2?!h@CEgdQ)hwgLm^J^#Z@;19SFW1x%Vty)Y>PVQNF!sb?!GrbIi5hFB* z-0{@^GVQkKmOX;E2kt`;d4kY?A9?F^-4x5wKq`~}XDxAE+@|ss3gF^bE2Qp< zt-`R!L>P%x`WjrMMqI>Tdr{&)K#s;yg9#wPmGs)C|H&hseC^shbOrvZ`9cQ3XP>M%p(T2+9A8!6aD{9j=YymW*hix+-Ja|CXp02y@Y#;x8@15H_T9R*eFhdMGY6SyE zHZu#0msm`7PFr6U8KeR3O#fI94+?Ui*)Azh_=M#>-k3bTf3NWK+)+OJP?kfy!n-W< zH=54E6SEc(wgQ{z=tydCtoKI9Su;@nCquL zV@%{8_WvQ%?w)x`Ef0xwfa?m%O_*1Lw70MLvTG0;9Q?IdRB1dcEH|F{c#+y2%u89& zLdE6JZnVur0rmXhB&cw6+wUnUliQMR1%w(h5E~*2)JgvzUkwWiiuvlfBFtyngI$OC z&^;Z8u!LoDVxj`BA|RFzy`Rna0;%}dhkuh}F`0l*rS(Uv;a%I4;6)JKcRXE)^Z+D@ z2ju|i56WhK{&BE~fQ7#<@_Qe&3NTxNlR;Yn z!@ImZZm)~eK{!URr(=&}N(Z%b-^Ig6uz|4w3C%q`tAm|y`wu za+l>yFY6iOnDe)HHoD{c^ku4~RU;B)(Z`q=bUZyz5c^L;BaxZj3PL5!R zNaibI7UF!2STNkg z5V`d687;_J$I0#z>QCeuW4v3DD7d^`XwRPW&=6`*eExixKe`OwCQU#7s=`wfqna0E zcDmq9<7Yq*46-&f##8vMZIyh85>FgQh{9!M*}Ov|?~JDCEfno&y1-yKxn9W@HnnIF z>h5J8hR6wr75p3=Yeq6Jlf#~m9R`a_Y; z%$%HPDxScPtokPXpjZfq^6eyQO4kY{Q2OR@R4(|MS#{+ObpLQ zTuS_SPCQhRDO}z*69-_|tHUJiy=QNatc*+*IyW!@25I8^kjV_Z<@hP7Lae$1fPR<; zybgPhUt%L~=x-&$oywV%!mkQ<&Fc^q9R2MJRIlqTu!kCAA0 zkptRw)}=we<)3&jWgfhL2AcINcjJ-HuzuN?G#u+-){1owemwfsEj}h@O;~g}g594h zO32dG6dL<|w=aTEe5Zen!5cb!xjXt| zlG}h`9X)!~FV86ZMD5{#!w)6^CPYU^6U^M)WIUy+1HXE28s!i}>&rj^^JCVmz#9cy zRnS4%Sy*Vtm`%cp7r{Ji1iZUWuHLsZ5J@|M5*TnJ>xLT`U16nn_x6IUhgwCbTo5Pw zGi1j68%Pbr<~|xYPE8Cqo+;1Yu@j}U;O>CEs4o`p?~g7W^!UTIN_*R)<)Qhl`L6?&|T=Eb%MGz5}dvltn&(lQ2dB>$_lY+_g^bG{oP8kX} ztQUq>R3b0V8eL+}dYAIab`=$($j|~1u9Toq+8nkGnncOJ3Tg`{JaiX)dm$wdGXzwv z%(y!)JCES_o={&^X1yH_8!ZJ~U~o8w#|gK_xj>LZ{pl_6*8uS3c)|_+o@cc=ybv)T zqb0!e*bDW<@K8VQGi*()P%;D-6FHOtIMTgx&RS{VcmZl)X=g`LH=FWr-|ho{WYL5; z4)y}rE3-b)|CdtoV~m1n3rSnhWELvT((<2s^$f9xMMY4*->j~FG4;w&r*kT0(vdhJ;+HoCG-_Ss+ z^BAqQV1o-a1e{x8JqjF29r*_4B~Osg4w@#|69SV{;4lLSEw(D3{k69Voh#3{MGQ&>5H3m_NESLH7ng0%UH7An5aOf8TAy#0bInEO>JO?&DccO z*VkhNJ`mD_)D0k2V3gc~+S20EAS7|H`n1Q%8ydO_U+v;b@A#|h8Ct6)6N0{YxI8-6 zctB(Tb;3GqBpR@5#CdLu`WoLu{?Vs6p{M@qA7j)7zkNPh=Sl;ixYSfu^DFyGEetc* zsR@Kof+<$mH}mOJBRmT{3@?A0onmLE#@E1&~FZE%n|3N|_TyZNDEIt}&pFf^kk5OjupX&A$zWv+%qVFITc zopl>?hyPHCS)|6!?)%f8#D#;3cmYzgSryc$SyNuu}UtwLvOXR7Z>3&XsNZ+V@ zFxCA%F3#A+tKwgAxlVKhU~@oV;?McGqhlIGEtE$#uo~+z{JG~d>~}}>L1v!yca8k{ zVWux;uE*%_Xcz@(&t4T3`0o1R$FuV46S8PXQmb~(WeY}0#!N4x)m*m^2U?N%VJgDq zhE~aUH6(iMGoLT0ug^|*xwC&_J#{#aI)kR$GTRRFXZ1`!%l$QTCeL*Qt__GP@dQl3 zdaz>~!gdUcscIy6v}jj$zBfHK!`{U5=`@cLiR9t`Gib8t*!8%=Wjx}Rs5nn?}NzaDwt8wcf4#eXt1j*PVy_C zMvR~V`ohlR3RK~>X;<2GuAvm}<{9mkO@!Bb!;NmAGiQVcat<9j1e2bbY986`N!W8j zZvX~KbJeB;#lV@uM9?ttI(T>Jy5bZA_naOHQvyQVtCyIAUxEsvzrzWH(ODbeEnByy z89uWNo8Ffhjf`HfWc-IUVwuJOmWgwfrlTOmB=)~G+$r|Q3|cSjhTV0sDLs=U!HmdL zJ9`_Ckx?+pw1HFQqg>R|GM$YQK`?Llct+zGALl94%`9crkm05!p*AxN`5eN1bs7ga zU`440hlhw3QBv{?G`gs61gQ+1I$rFGGOeRv@ql~KMH3{H5?>$c$iBC#+l7KdJnb_! zK1I{Qw@9cwGBPr%*y!9334c%`|U%BiitIxnd9K8Sr4ruzzE{~{QO%`wN*XW zfer!?jf$wVdw`$X6(W(rp#;aaXDj_CQm3J!v?01{`LJ&%nq3xaiQk7SQ2{PFubX- zFB=v&cTscNDB6hO*mv3A67!N2h0C8$H5D zcT`Q=I|n&2#rXyYL>MWOT%4TxKS{BHxVZT4-GhC7yJTg*fn>W=>_k_bMA(z|_S9W} zY59Moa}G*)=093aRJ#6ymk1ZjH#yn+XSGFnsW4aIM`9;6f>IZvw;s8EEOQr-rb?^1 z|BUPd^z>%8+Oo^k|5OQQ;6Lg1D91bXv$=RizjTI#TO(*bfQ1`z*x~Sd`yj;nKRq3E zPE+^=NE-0Rn-r^yfzPVnsC1?d%=)`Xr$VT0zFh5pc#k_i`ULn&P%CivYY_oAOMa5n*)FF0p;i*F@)UA$jGrj_4si04F_rP+p|BX#t*x{ zpPdzRx(Q`xxTJlVRXM5B0yuJY+F&gJiC@6`_-GMvj!(q@2d`mo(H?Ds=7E{!giZmswM^=3!Yawd;B=EY7R5==pm9(Fyh_ zie?y|7|LJ|Q3bh^wAzns=DP?$7=Msu@EYh4x?O;i_6!Ea=pO@eXAd4YFnC;n@663N z*fPE8t|RD((U9qZPwYa0EEQoAJ9bnJdQfZr1|btRQDjWVE+r{CUpEsiEh>#b!6??PkkE$0vVqXl@m=5_hAaDun^v<7P&G*m<3m);Qsi^_U zLF8k2OLe>mn zkZiSW7cpm6S3if+)Aik9Tf^f1(NP`(c`5qOf6)~b7Lr3JJRKEf<#SLKL!&a4uLQcZ zw6wI_Gq3P$dhfmhosrldSim?RsR>+3JaR6yJekQh5pfLGdbGA$1_p6mE>r{p5Kc%O zjvcxJ?n3fMXzftuKgG8J<*W(oMlL6DdKtbS_!LA>rk^k0p#M`{P%x5lc-_LxP*?2# zaslG`y!b(}(0{cN+&U6ziHvMTJD+>?DlZpT2-e~J{5;NZ2n=WmUZmBcLRfs5)Xmnk z|H7g37Ps!r-2o~O`G79 zcT21-r{u87)Z0~Nz7-INs)%ywQhU>~sID?-sL?G46ua1bT{zk`aC(vQv~O_+r35VA z;2TjeFpE0)%w#t^_+Pe;LXq&>pCQVa7i3DU%6oLhqZwvs!g1@OLT&!<({D``m$rHAm-Gq01{l`o=$ftPS*WT%D>W7s zMdif-@Ib64=Ivji;ItAab2AfPcZYw_+5^rd16cG`D4RbE?$%_tH>@h6tgSdo>=%HlV%)iMC zJon-34*njNjh`|@`fn$qBS00!NuX5!HB*p|#qtgwOeoNnf>%JjfVx)rz5o$kKSt3l zymQa^-tYHPpHn%f4zqyu4$(!0?YA4KjsTD~p>UARF@~+}8t%MV4tC+t*bgiT%v$VE zl&g2&U0kM*`nT*7V_KiA)z|O-z7Ve`sYPqaE$F^ydLhwJ)P~{QE`nBAt^RLSw}&V7a_i)8=07GbmJBI> z?ld}p72!f=+h*R*|NP-YYF3uJT#oEOQi|ne$qo;Dn@nLQ-Zd4UK~_v;ba*dd%@y%P z8hm%i)VT46X~Ea*^`%mv;Lf*~`!1|)=%E1QcTvMwA2`4UO=kMD@iB%_{a`jm@`ynL zyEBX&}}0?<$R>^Zsw=`ZeTD2p9y{#zP-?)f4T#VaOl$ zzf^6_qdz14?Xr(@2#>#i&&OC4_|}q_@EE#%AoRk&f9L3$wmo60-So$wMJpg;>37u9 z%F?@+7gib0K9cx0GUde_98s2cVLLmTUnJ!s1N-_hbHS`r_umV+c+LA8EA>z|MMr$B z_prU+M|LifX83xH{-P5i zj1Ame_wYx1X|x%(&_DQn;R=(K9_@{8C(h|+&q{rb(?Z`=5N*6cqmus6qKWQEs&~Q* z!A?O={S}*)5@1VMuj?P9vqv7dmj|9ZrG2uxRWHspRT9nKGz6o9&Mp<3M{NVz~{6GnSN#66n7* zT}ly-3u^UfIL*v<6Yr{DPbn2VSNBP#!%7%;HEe0v2qb$S=vZy`%?nh?{C2`Y z-Qrs_7r&5Hd+EK3F-z$>DW6$_FTI_vv~omMZ`)wG|DmXE%3JCzO2B73%0k%MuP|4^ zetAv?ku`j@>${pXq84R{~MEsudk~2QH9&ygW#jOgR1Ef$Sw`@@iW#be4gIv%C4ULQUCSpp`l-00~J7f zPz!&>f1uMu2@&_G2JpcC{#DzueV7z>wUZGgx<&p_-cb5bp!!MF8X8GvOFP}(z^`pq zYB4ca(RaKvGD!&Rj~r@00Jo}cQ}D2GDZ~xjf7MdoIwvb@D^K39F9ax%hS;7a6QZA`ypy# zV&eBpV3~R-&#D~B!pa1cedzIG8I7L(!|D29n^sBc{0~N(Qj65kHk{6Tzt9y?0rLY^ z^#(yqaBlkQd+Z^K!1g4juQa-eoc!ba^+Q873<51R>o%zIuL{HCM<~yhfk0m_zy(-D z^qxD8jGc;#nVx&EINxSK@e}{E)&q>Z3_X!Fz+fJbK!N-NTonDw*O#Sivp2e)KNq35 zf0F{;Tgtysc;WM;JqO^}MS7+8=t>GmODB8kzY&XImYYWMt|5P%yO{x^EXm+aTuHD1 z=>J%zbF$9P+@F=ii~mj>>~_z%a%B@?`EB3vXgFBum!&1Ro0S4_fFtEP0`%@0SSPCf zGohBH?17k&(rn1TLzIAxm~~68aTK!vXB)lTqngCBp4K4;>1*-^qJLPTzRNh#t_ydN z-ua-%B>R&^5Hv+Xf9H|mPQ6Gb+XM&VZUDHob?AE8_kpC;P_Z5s*l~ z?GUOg^DlD#5qwYc)v^+Q{rLIw>aJrnqXn!G#&ouSEOWZ<4$|e++#Edt5@bI|6UjjJ z@qzOw4AmjTv#?-}g2p3-ZX+7!P~3(kG#O3`+q-rhy|AGXJZF$pfJ_HtV`Au3Vj;dj zbxHy_kAcc%s6hvI&8B>>AV`NS3QIzL(4xr5V`C+)%#ZW)#PKH0@L)b=zn7J33Tf7 zwUV=-?=o>CBNnNaU=-ab$k6mCyzn6OChd)~$Bi|0KjojFQa>lgxt!N^?ucl3(z7LP z%eV>ACmkF{uyzveoyQqrDQhdGqLSkYUdz7MX0)_73@HP~_q1*>Tk9~NHqV$wl?8Yi zRv4PhU|~!HO3YIaN>v}5@o&UwXbfiscyJ0P!EnNWn!VJaDIm%7eYSV0EW`f(W;j{* zyEVGX+1l7-Lzv4)3vaq-FqFZHXkAqA@Wn}wI+aZLUH5o&^gMgL0gwo)mOujyJT4M{T-Vo;Jby9A zJ1T|x&hv_|GKs-0t>+>fSvOfNs>p}mQv1m)&`GU?;j6rSTC#zVl2Vpr_zhFhvg;+s z%*|Ox9@9%}z0&PwxU$*F7EIeI7kgu7BWGYjP>hN!aX?3k!f0!qU8SigfGRLWYeQFp z(W_?lfbI3!Z{vCEY(Cl|QFaoZ&4{~UNa8UB2uAuEb?C}|L(ea?coPG^QGB&JyU&2Z zz!8O>o}EZHoCMEPBJWaY9;V;)#DrhaB>l;1Nu$s(t^)H?{e$CJT>p)s%pp;Bf8rD`=X+4w1Bd*jl(H6eqiAX zzw$|$tq?oOI=Fw znAbtfQ1kNhfi}awQM*H2?}jN!9LHr$>TWRzXMqCf@9}wlBdd~yL*;EwO=_m+f_#7kq+Nk=_ z71f6Ipe*k!H+;=?(J2_&vm6{W1g|kyb{iz_GOwlNcbO9H_pMhUU3_S<< z0QekUwScFNvii!UZOzS2(9q%BfrEo8W@3JRl!LcUpFIn^Q*v@~GwqcYa4)m1I|i0}ro=hm8&B9Uw=D&sNCFD6>-=v}qLy33~Ew zG{T&>la-UYkhde;xN|eL1{1MiMNH#7ap<&K;c-2mfxyNBq9YL(0i(Bi?U=JMFd zZ3vu&GLU$Id6FCh6~D`#`Cn=niUJ7%jl~yh0C2UDeqJ7;>=B@AW_GzgHlY?yJyyHvw~&-E;xWZDV>C=4ikWCFd&j#$QNzcH_S7d~)81ArJjo>W}?V zo}H*KZ8Im8zu^A3bxbk(qw=mdB(Y94QCw3x>k;g0T9OIub%;?|M z1US5_Q4EC$*22wJMPVH2Kir!d8mKpJ9B_(artn?#oo{)i(IUELd!;x1X>lEEt>W5` zC|>agbQcpGV`)BG%C_&DJs_GC*{|kkNE~AIDRpdQa-i`Uc|5)DD6Q5m%RBi_+1M#m zM0|Wa*laGm6qW5s7bPnuKYWNNHe#k|1?10q50cr~ceB#fGt;gPnr|Bj?S;k#h?=zDrbp6+-_7W*EXx6Z2os#$O-)FTvmi z<~cb3UXb5kR(J41DLWlSl|UkHR*!pB_!s5y&DZQ$nor*KJMp=`R?)#jLb{GRt3S^u z=z`ISr@~4TsdJ|dmg-&hN|$nEP4ox{`^J5FyT|&=Rkp0hex*{HFH(1qzm~Z>2p7>) z5;-Gz&4D)`+t8WCEDCJlLrDv9$i}7 zaLjiB`V)MpW&{Kx7~dq5roK8d*o_A|<`Yqdib;`OMeai`;QyPKje%2>{EiYs+3$qy z=yBb_>YSKF8bxeN$-AO<~F8`{UnnG}V&s67^BIz6V^g6!1d&6)_`w#0P`&&a= znvnn&VF_vhb^#?3>>Gs7(zbvp1%}p4wUj6}5fjr!%*If|9l&!#oJ?EyDHl14lqw)i zpcKS)G-n^mj?(g5Ys>9oW~Sx1;(?YElVQ2ph{YKP+% zP0weCE8ZwLt{tY;md?6_lxWX243j^EzDZ3<`G#-4ZXa}s*yNZ{cWxR!bCZiWg3BKZ zj_NUH&6o)6kMU{-|MyA;>>I(?y8Ey?auCL;r-5b@eBjN&!pDD@!}HDEN)lGX3(vbK26PL)ZDYxQp3Q> z0pk-$FJ+iBqI!%615z0sNoTp!<`=--0Skd}PZE1`jl>g}w6VV1Zq@D@RUg@V^ojd} zjgD#SdtU7S{-+{D^g)e!;xVRIU)4Xpyv9g=UoI?ZtW?7%#!^4=H~i74ZEX@M038EcBBkoX2(+2y}FQKo}@M#`ydvk;nl zetFI>CPw-yMeu>!A$8N&P)gle5}SHzUH#z}>vwT$}?c0AjaTro7{`8o1& z=A%GZyZ(jX$wdx^YJiuj8-z`efo!01Xye8hK#Dk9Q_i4!La~uv(MEa?o6|h8`lb$d zp>uMkXTklc=Zw(7wOj?3AmM{`0mjFh7jhRtV!~lKq3DeJ-lOh{w^J49CPHal#Ct3n z_|9K%wIUsH@ekB{)s&S{_BovijVc=^@5`xjSpJ>FKfgO@`4gPWlr!{yf6PW6I;VZ= z;j)OW$Q;%s%AUejE93G(#_b9v_4PI)X)9R2sKiJXOPNT;v~@*qup@V;5$de0*Sx>; zD3vLetcDkQ*=j?U|BlvA?9-S#thuO`ZN5&K*fxqVWzHa;oVCO4D<5~d=YSca=e-xr zDj@3vF_qa}E@-W7yuGDcWl0AMgw%|pG~l`F&q!gD%aKGPa%<~)0tT802z70Yt^?Vu z6?=c(b+~YW{L90mYyKBW+MVrymv`)y@M?d()RU^bTt1xGXRQnYDZw!Ax|?eI^GRcdaOS)pR#JJbwOkX41vg&J=i zRL`V`qiyEy9Anrh*4)@Mk5QH2p>wK;Ww>Rs1BZ*&V7%F-V<)5HPO@svN@+Z}WO~K) zCeY=#=N~$XtzB2j>Q4-l)qkS8;CjGpDY~q_Ouf9@su7(U6Zrz&o?VAtqevW2D zX%*SqltTc5ddP@)fxZTgE#aUbRM2Q3*@c{~qoO0O56o)q-=Cx_INI_DfGFv4nJ7pS zi+Q+t12P1ANZ1(F2Is}D{|O>EpiYAm@;qP@bm_(BxG0Z~jg9%!tF_BM#WO+yzwSNQ zLHJgM_BO>JE>Ub6va*J79^jZNF+`VwrL5t^sarJ!ruARUjr{57;0~e)SgF!VBCQLU z`9+BlbC~;qJwru6E(W@|j8xr)HjikokxafHIm3PWz>f#N} zKDL1`^kk3RczxrO&i4}2>z zE*gnLa<4+zJFotH_yP9Em=s77F0QUgJZi5*;xEFKDGc;h0xc!fHt#2XAUzvD)e)y{ zxa$bk4kS+N9>aVdH_WNYIxz;C{kS&1zI(((Afco6N}{D3R0ePqrXs+_N^>`oAnED- zr{!^=VfhU>HIWbkxE7QLh#~=xEj`ITAG55a|E}R^tveu%!xyfRTC1e&9M%pXrj6lb z+AO@vUp>0}8Skc(^%YRjqR1U)q*c?VER5^ZPMm%v3Cj>XEhI%Uw8CTkJ(zhn?fGz4 zIC3}j{F9FOH=a~(dgbxEuL8#lyYFfZUI=+Seg1i|;^ctpH``;>qk>CKoWg~?GK&dr zD+MFl!zj;BWtJWhDDQfu(%RU&A@Hv2-iHt;%PD=q`v#71qt)srbkeB$U2IzMz5&1K z-`huP%l6cN@5KB(HwVWQIHI&Ic)>7F(Y;qK0{GVV+&OguNbZM@9brOs7tPpB%LgHg zkIc>GA|R9WK*4PFM$B236B}16vm_We6R6miIt&^>2qwAxImRdi{w~eo$L?v29wa%) zfU&l@DMK+!*5^`a&-6voDsAqxki*%ZKsEspls^#g;l79DL|Cj5V21aB1;fL`GpkE` z9jaYSI|2d%rVwOz7740fAI)-D?XMc@&E6IHQfd}hBuH4Z9JKr{I-DtgYw85Hxh}@2 z{dU%v^STjuI;dDSC7MoG^K4%4--t)=;*4&~Ovps_3o+NaN!hfI>$IO&v_E;RB2;>= zhBsX*+;V+2!63_&rk@y0dAc{pOhF;!;{Xzn_5}YZxpix2?;==+ur}H_+T!8E{R?$A z2te!h;eUjPF{wOA&}C9~V5JIZ4mn|i*9_*Km=@uVxBA`Lz4#B;6{c@=gm{Lyq$I;u z&I)JJD+}<04B%wdQ7~_DjqBX2rc&=+!=6WVMNQxkL3{L`)zU;3S2(Ws_4`0vgi6oA zDyU8o^H7A#Y%1M6gf)+V1IHn1twCvJWpbVbFapr_p_|5}-69^^KISMu5;#(J5c;F6 zJ9fxLb7vHSheQha3C^T;C?)OO4{uNFQb0IY>ednfF(hqnTMV<-t3)$J_qn`d7G66Z zPEnRC`fxiM2Uyds`|T_%ywI@rG4=lP4{xniD$OvO4K*3@oa~(*^4@8#t7mN=NmL9e zB_0ZMbXfKJ#!O$4Tk+HEmHTE{_Nps`z`rrFlxKi2h>XSov7s@V9jXBwNw!b|@X_LS z!q8J`Oq<_jMRwqDJ8WcS(Yh1=qbv~mc(c;b#>NKT+u$9><1s0mS~aOsF4MyI6v=1< zG;M$C!5MUWjXHH8U7{((uwp7`thsp{7>PI114n6de0_Wn*d^5U1)MR4ZF1VT2Kti9 z*gJdyQN|@DQHP@=Tt%y@oKEmd^kJ*Ojv&g@Bwsb7g7CBiKMgp7F-dx`0m_Yu-V8@4 zL+NoPoSy^|-a~QgR(jo$)MTD|nnPCl1W0JmyK`ku>)z%TNcmqb0PP6FtDMS)OTOEd zs)wT;Kh*5}9&|L^@iY5l&uqsnwU^ILaju4{Nf%!>{kHqt6^ayR`?zrXU;Dey4QwAM zzY5vhxxPnHwiSo(zE@uvlmiO)_kO`cgexIcE0}gupglhTsykZH2%EY9%pqJt0ADHx zGw{FRyH3HYqBZI^Vrp!)fnd ze+7k*dCW$_Ci5`#rK41P+QBJ(LP3T&|F&)0{1ZID4}22eh-L?eL9zXH51nAXScEjI zCeF7)eB7BISyQtJ`u>d1?r~*lIk|qaAh5|KO`QNkjnZc9gl7PL+li1->>G&iUPzUJ ze(?ukb)7PR!UN!^jB}XQcJJ-%9&WGrtlSUHTbOPJX7=Pr`B3}ITln60Z0tSBnW8~d_?5`hrMUPEc( zCuzG?KzhXz9Z`8?<-=4JpA_I$&HDCd{uQV)Liak#zpYJlc@5gkcF!%bVaU+2P zze2LV1ZA^6c^E}mD^{3JSYX=iS(rKGhs2`mn` z^UiCfn10~D&vICb3JM+r)b^or7MI=(d1crKvRPO6`}Z!rb2)40-kffzwBMvqAx`E` zP7mk8Z#(zcZ$_%YyM;Z$^2tnK4Vk|-i0XFKei;IdvOD`~MZ6%&Th8?5E zm59vo-sIM}qvyB+E<6ivWqEW*5Lzfv?jzl4*Vn1{@2gQ?a6dZc1(U+{@Q9XuiX~y= z;9z{OTK4Mnz`#J;4R`U3O*_IR1Tl8x;|u?{E5mRSEwts*ei(PCL3LkPD7d^EeIPhs zv$~zCD1UOu5J&w>f&}`G+LDr3Z>Jz20Hck2Dz1v9b#b7{zqili02c12!36b?8cEVk zyX@;@9Hh8{(T(6a<>uxty?nJF-TMc6rEFI2wF8|ZN-V~+{(=_LW1>DP=N1l1SIXxP zh5o+w)W0{$XzuaJyEa|lKJUZ{gW^i?(jXxVx{OCq1=wd^eFfCZJeUFme0gi2^k0dV z8JPeZZ|XqswtjE(c}AQQ*$s>CwXhaZT`!stC6#Km71qLp`qr zq%vjSSzfxxVSC)h6*+MfmXuZ&78bh?Y6fbk14V;~V8W!#ycC8JnOBdT`Sliu9vqyu z4!i~CtB>NFi@*FrjOIVhNMn^|Mj7(e% z5ih-d-4PR9mzniF6bO$%3sN2JbcVbV5=c}-%f`reEC0^BoAS`W{ zHu0@nq6z`reMu?1<6>i(H&U(N$IcBiYO>7${3*O9&_luG;ywt*ql}iZN2;zNY9t|n zet8jgyj~-ewthA>oG-Z7o|LDXmtx$(!AX0ro6G3K4>u(sUC216B#{dj-QG?6Ta{}*Gh~=!EG03k(q&OekOp{R^qAuA=F(Ketv!^Lp77R2*9Q` z^*%SEX$)N5+!(nOpLTT_sP{St9k9QglQROh{)I@)reR@vIXXEFCX}StPbGJ@<$`su25?-Z;(#t%|B*X;brJkQ1gaq%}Uzg=Y}z# z{qN&9Gz;{KS)#qLoS&fv&-xu^^L8b{=)J#?Lmv$*Y<6VoB9Qgo{?2%(o8&gURXpf1 zRB1niIom=MQkuy;O~}w3QcrusS2-~7H%if$9=n&98Cv~6zdnOv(k8CuB8I5=-Jz>U z=r91Vv-a!J+6p#VYLVpb?FaCW;9#)RO`+2_VtIED;)QeY9DcW@ce!AFqj#{xQ7$C* z6)7N?6x{%L^nshP*jxqWoY5sh$GjOUj=QiA!)Kh3jtjI4-@jkMkPeSsNUR5BBrN9t zG3AZ<2bc(O4{5B|D3+@4>$^Z|ryyrrU>d`fis9*zMt_6U@^fx(Ziw+gY14Mbncx!= znm{3*>fnnZM_CT9&#$H&VV{h1&6^sN^4}W;e&(0{bx68$|FU5HM_cK-%~@>SS@T=# zr+peP9jQ86<8}a(n+kY1rT0xW! zg)47hYTaS<+Go$4VLzQkydNLGgCqONlPB(~2f^d%FaVMcpcf3W-|KBNmwx=fFk5Pc z#$_E2Fwa*NIm-7L3C@cr;QEU`9Z_To+n)`D*{dW=NJ~HO?uK{ZJKWFWtNRF3@eH(p zi3tgX?;*q(&xj)uCg3zboj!3;a=zbwz*dNJYCtbaX$^Y(ZzE@bq6SG$v3r+ zN{5u0UNL<1H^{=qMj2(rovT7-7%M?u3)2cxkh}6!eAkK}s#{zwSJw7rx}F>?TS7Z) zdJ0tP)`q1{5xkfd+6c#0VZAj2<|yz!a6X=cGEhfH=R;_N!2cbrgw~xowc(O3E-C^>v7j!qnunf-WFDY!W$)1xuz#|6 z4u7yYU^#Kx&+o~~rsx9PU^qF;k3L$Zm$~m4nujZITT=Un56%n9=r}N14Dj>&Uf5ya z@mCAIOv-0iyr5Rf zN)p@SKq2^ z;oul+IK2~ug!`wFEh)Q~9+O{y54X?)I3G500Y-}z?1|lr^8?TE?N{7nc-|OeJ$}5; zFN2Aemd|Bv5^B*HPbMG9}gY=0Yyq;q>A1Y7$pprK?T z`e$L>NVlv0e%qgl^;7RCNxeKrUVLF!2#Z+2Vu5Bdd3{gGYdL8_&A|(13hQ1yj;rYC1Hi_1Z@wl~1P0#JsPux=mB&2agY$|+%Gpam1p&W+A@L#=&=a~yj9uQr5bng=KqcgJ^nVDapMngdj(vG4@ zkTuatmS_08&u~kV0Us9Pvm7Oc(1HJhiys=S`=HKfYOcd)%H@#0%-9#On?HwzPO9!k z*fdThr##y5-szt7N4bb4Tl)D{zldIHUgOz_h0N=tg2(R?*Ni&W?VD;7y%K)x?5i_E zqN`Gc9@iAMAHXq;hX^@$X0H9%wYkzel3dKp-*C)ej&d8=iU02%x`lm%gO{a${?*E6aFM+0Vf1`dyA!W{xA}3>JLPDma5G5HR zLuDvZNF^jH6{3tGa~U!xWC%$}94ZwN4Mb5$X2Q2m_ul?%ecyN1U3aaDbKdj5zu)tF zp8f2-A5IPq4v=U0`I93fwad=k!84{>no&)vA)&Fhv1w)6es>r)zKD&OTF=4HUxUhj zLhI<>yJ#|@9;Uj);t;g9w*GmNRWMLfmQmP$+|upxp%nyib>FJu(wmnk*IkbJ(nY2A zigM<$hIR&Y26dhH4AtQSK-K?l;{oME_Z1fGz7);F;20Az(8Eewb!<5c2qVr@>c!x; zc}X2SUSf@7s=N3oN@yl#W?1rX-BBt?Eu2u2WFQtHMM5|NWP#VtomXuOfk*mOCp%ik zK+cnmbR8c1w6wH*TaU5A_vhONUI!L5H{jFWlolBgp~cKT(O=)y6;JUKDK4si+ZcWZ zzAV_=MtXXnfY{=#4<8ns8|4ez369&2{b zu&|K-T!|Hm7{tb{z7rYdRqn0+KUD-)*y-6S)6@ZA3S)_WM8@`3ntKCE`0|?3UNDL z;3si%!fnB5Ia)L%EDQo@6>$bll=lZ@(l`T2T3#V9>{ee@IL%3DuF7K)OSpehfj2hoG^<$| zYC7cE*Ewc*uTpJ^Q5b&vHoL(hY}Z42?1j9G0j%2i6zczSpWd@Q{QdXZklJg7MnmVV ze6{#2Gb7f?_x4Y4{~A&+kLI2Y?QGHaEdCHWPYXoeviJCd?MLNWPb4AXKsqK|!%6qq ze|f+97^O&3}+5UTfhb#X|uI{={yVJ)$Kg!H)a5m=P=gRGj!k0eh7?<35 zyu2lLgU}a0>2G^i9Ie?A6}oS$ll9dHie)V;Ob(=+0p=IJV4udG+wg$ua+aNheU9_$ zB=%;hfS`jg@Vs?6mSElbt(qb2YcC31-R4Tcz?TYh0C|l>9<1W_*#n*Q-TOZeXItVs zzu5UlkmPY}`KPd5(@|!Qt^U!@`@_ml?+}efhaTwei)5j1dx>a2xQb`HF*&uL4aPp#U@su-^J}R3%I`wDMHQmh`9SFArtk8j#qWH8 zR}GLz=>Bhkrgn8q$p zb#INI>W&@BHv|Kd_g~udgR1mJOM4f12j7Bm#AA8~LMIx*hR?!JZ1{3&0)seeAY(`B4%U_FM%mXVEY)6& zy(_xxibm06S60Ev9^ms;5ONUKk1wc8Nrj`xQ_UNtpi4aj@Ey!7bLMZ-XcgQEw>1GT9?c>uE;aL_v}pjRNyM}BCKckmZx zIYfuyw&pN&^mBB~boz6wpI7KvET}!2P?k++D;E6e^>z?hX6b1I?@ZNxjXqH;%Y341 z{dnJ=Vv2DuRVeM@;0s)cDQf6*$vhe$ zF_;?@wi#DUT=n3mbcE1#OnheEzaphWSJqbI^)T;GZRaZE$;~M$p}TXQ9}Q%U42XssT!-Cah9zqk|BZf1X9F0MPq!yC2nwx(UV$=3YI2E+)pp*H(rxupqs7|g z34`@R%(!;Ta}?x;K^X~W7-Ts-g<=A00;SMq8Tbu&j#yne%DyQu z{aXA}ElR_&N4Vz%LPNsgvA{56B8<`VU_U)|gkw~k*hko}3fu9(%c ztTSIs)}zz1dVYUx{M%1X(W%Gk%0mwwAB|k;P_*MX@3S4DRs1(TJ-mRTT2fkC=_6Iu zk+>nJ)~5kOnYpO#3M8@6v6rLw`QS0C`sJmu<I^e^*=Q;YU3iR&9IL@X(8kFkh@8c5x6*G_tbr=F)J*xI~as4lLKB4J1#WN zg`2(cT1fSESA00-WSGkxg7QIkdv0U#_A)Vv}fj+fC_P_i0OSyVxslvQ6FfB zeYkND8XQT2B9dn+@R}v_6CuvLLC?&e6#^5^GM?gE+E2<38PGpDpS-qZTZyCE4#(pH zk#ib{svob^-G9hNcJC3`$>Eq-_*NZh>ppO54sEy0JxR^7YY5Ps!D1N%89?Y29E7RH zp%gw4Gw5D?x^)Vdi)n;DGMWI2<_z{DyihRmhdj1|mF_cmGk0c4f#FpC9UvB*r8sL4 z_>IPNA0EQA&w&Dysc&xtuu#|!fs!r2QnXj@7p= z<*n+a>)NEn$}F(M5J5QrY?s9a0WUSZR3K>Jf!JGe5eQ0fpftAHGM&9CFozzGog^WV z+jH2m9t0+p(TD~)R_%vlNJMWQRBb!9m5N49+6J%Inc2l*nhedpi zlehZv_LlFS0)7H^Gi}vlFXj?Aw@PG8Iep)AZY6(tGX03-sGFRQ&5;97Y|f&@psHcJ zUr~us`$JnQ-6mle1kMlT1A21H$gP81SZXg{F_=;JK%R+ZH1bUm*n4GzBoS?k{?nTc zfhKQ|gpCe$N)lq#R9FW)0Y3z_UwziD>|)f*7&)}D+6m@HWpTLK{(@%li~cU>cPl-Mn*JGuSCMDd0%%L3FA>fP|w3p1RN|*W{dYE_q7aX5D_nfe@#c~iEeoiXwG=3 zpf~P?n(*W$cAsSW(Zg4VSMGnf+r?TvzIe`ur#TQC^1~4=P3Wi5)+@c+(ERposr&VA z6p8>etgFtw5~Lw!*f79jFT*%^XYbwvF+>!8@!|y}*oS1lonBcM4O7zsJA!bm10XWO zG`a1oNR&QYE@o%Ho!*Y@Un5Nw1}?zHePMh03MbFV+RIOBRc7!!00e>WF5pQfL;I}G ze^p8svCL_?_Gl>VhrUhuz!eQ>3KV6!`+}NWN|#Io{LT}Y`vg<~ak%{SP~@}$A=Y}YS!Vx# zTmW!9j=~@fcGT8*Lm-^5ZC4Qkwmv@ds1E=wQ%P$e z$n;(i6;L!K!$~n6#&q#b#!>}>90wZtxIX!qXppMKP8i1l96x5O76-f!^f1!dq0 z-|h{D+y#NZpCmG!H4%x=we_}jqIfW9Wkb!krELzjjg^3{0U*OmP+d_`5gx^GgF+d& z5#z=mKVAk7>Q?maQ8|@N1=ElRWxxXcu1{ECwrJ(Uo+tkth8|DRTDu}#P}F@{R@J7_rAauoCoNZ z47MEBUwZVcHVCGgaT!x<21a5B2js`Rt|jUJh)W(45YgE<*d0@$wkz+`*Xew*CAC9$ z^@gR3J2@C5z59mWTN@p*-MsXA!1$6PXo~9Uy{)aSAEbUi_%b-i18kTJB6aJ2 z&J4g3-TQX=RM&vEMvWIUIp+B}8!NT+^n%VZ7~Dtx>T+#^Tw}7&u=t1=q3P{(otyWy zL&uCzyZ)fd(cqv*3Nsfd_M7^5)Ekk@JhQ?dc|2Z6%1+rTQ^XQf!bn-)x>zGz7xu*@ z59d^O8Xs;7S@pCV1bLq@7Z08hWvnDTWE`{Lw@O(*A&8l4)35l6e_jE;d*U41L?V9G z1RcL;yEP?N*I6x-pXNKE9RZeh{NTesd!zh^nz; z14}v;OSSd3&3h4kVpKg3!_fb{0(=dv(!DeEzTIh5H0>X53AI#?u24~U_@!pz+)Ena z$qh5f)>V!tRR=%BE=UOvn!YSs5|%ED>|kOR3{cJJ{LaLZ^zeWr`Ue{DZMLT)Mg>hP zY5sZZPqwV>XZH=dvOjmb$L&Y%laODae~>KfT+MYTeOCS}SC8){cCJ=hFZr?>N;b2c z{VNM`^Thb=9?PZav#e|p(R0@ZV$9o5k)vqK6%aglG02 zK8cyPZ)t7OwdP9nEjU{B&g;Xdu9zKieUI(MZromtbPLm99Xp7m;9 z`TkM6k5;{MtjfU+`w1^9V--$956h6fE5M!PnKw~xK^++X&pmo1`uvoE6NM{SCOh^L z`G|7-GGFi-l85>oI{8kEiWlek&*Hp3k~y7jx+(K9fB9aXp}KRmB2M?rWH(Vr+1Iwp zc+M8CEZ>ZY!v8Xe*%$?p5@MjM?Fj~Al;(f8Y>z%uws?D$X;}W<#tu#kdV`5ACQ;Xg zIQxw_E?7Hi}J6VmEctV-^WZ_?z#Skt?$%s*aGYQ*mFyxltwtfyXq;evbdmVbnQmU zyWxu#Wu@i8^sWvCy-Z6_(~X2?xU=u-A%+LMr_M}|we@-ap$3x*(NR62^!w3@^0L$2 z|2}vkAb~x;-RIV?>7cxfX#WGMyUCQ`Apug>PD&}0!VY~(>e}e72D+mP7U5f3HCVg! ztQ@+91Scvw8T$CUWo!MZlqGOhA)FRa_lXVy(Qpm~YM9e$CC~=+T{eKh*`L>MCTDfg z*I6w;1oYD*DX;h75VTKr^PK4nUSLy>DIa)WR%_JbRrJ#0mNN$?p)^fq*ZJI7SiZ-& zqK6B94=-THjnQ{PcaT&gH(COJ8rXRE27;60hb=P1KQHCY4;jCDESw z(=^OS_9Uxy)g1P+wf%s>5~gNvuM(+om?r!0$6TQcmVA)RhMn>%xzB(veuvSZfbbT{ z7jj3W({t;qc8Y)YP&+0t!`~7r6*P3c#$f0v=crtq#cX2Ss~wvrrgO$$g!?qe(YyPi zp8~%U3`gyCqXK>xSm5$;VYXn^UHn;&vU<;z9;n2 zFFAUAB|Yq(X=Xp`vHMD+!By}>%U})gT5bi= zK>Ob|3^=u0@4asPM3mm}UE9@c!8Q)lTb$J`99Akv^gRoevb?f4=KgLqcY4pUXKT&` zRamn`OHpPeS-M|LnysGim~e5`iGTD|kj`BdjCz=}Dx|v(R4dqj_Kc)wq47>Z)c*r+clm4_Rjd6YIIBmb~Z-n-sDDnfiZnfBk~Fy6!9Jh$74!p^wy?J>8AcS*lk zqMFR%URZguNu8qC{}i$k0pOc+WQpiL_nuMTdMHBfChSh2!OV1!;e# zh=<4{kHKE)euSEB303ii&4hbd^T&@{{(0&Fr*sFS^kP*K9Ot(>&+W%HG2BH7Q&DeL zTYhGpOfo6C_)CBM>&1f&G0gHuH%ACLvsv7Y^NH~d6Tg|^Uw$0n1jo5EDxrMy#xR8xX(t+gP>A-YojM#X;Rv-a`jVF=%F^g|JZ~@Nzt3Bq?C|{e zM~C^XiVthnA3T_86zxr~pKik{wNAon7qLirH|sNQI?}0N9Q4RDr4X@r zmAbIv@G5IHM_H|bYIFQ#Rj>RKgU1| zf^On_f4}`$8x%}<^vsMz`F@O4`{xctTak>M3OU60xwzUOB2p4}yU0JUA~C9ao@}ey zHBsAZ@J@J8JsCka!MMLh-SC;U-QbRiU|;Gfb*rD0CS|$j*d=1Q`Tcuea74=MtR>OEWUJ>AmV(}|_a$gO97bVbSSBtwgf%KI6= z2;s%@_tBVW5AL3rA*B@u<%%Bp-a?s!DHO+cr}H5T(x0Bt$J}S4_)pU>%dWwPOH$76f0Vu&vxQ(HVfl%kh1mXVv?|~@xB;BCnvE#H=#GBn= z(_Fzej??R%t2NVYcgvcrh#MC-KODk7t<2KX)-x}wbK=MzUAt8wjP_S|aW0oqSog`M ziiFe`$orm%Sl&e}B_XJ6b&kxPm6F!-YT=NKjEIo6iOTU zSURwrB-x00esA#O-f~zD z5v!s^1Y6x-otUAS8NA3Bm+kqaD_P!7Xlh-yz21>B$MUsEP@s{s_%VW-U~1^KsD{Qz z?3HCqg#33CvfQSKDq1@EgmV#{ymtjf-G0aFWER9`1RCWDdpAr&%%bqmg z=TUKG{ltAepD@X+QZ^fgi4D!PN)l-}+Kni)=}H6(t7qQ@4cWraS7_z!3y=XgNU*Oc zUk6MC=pPDE(t|EnAXQGMPnRgs5z91xzr{?fUaa@ykV$dU!qRa}3)2!rk)C{gWjWcB;S+D2qj!nfDx#LnzTwmj8XLn&1AAGl@ zza|EuEeoARZ0t2rTH++%|32#aRH)tso3LyIu5j_O-7Wh_L#&@ZP<->)&t9Z%d2ZSM zV)FKDM6_Pdah@5QbJdhcFU>GFeNx4-Ri9F18zG$e2rZef7tRvQs)$E+y0r0sZy%`_ z+jYF*t(UIrM#apll;2n1kRx@Olk9goxjw0S_A^!8;2ddYy{-YP2Zy$PT)D~Cp3W+R;VWk8L+%*jTz~rXa!?>EdcJj6dxE!Z0NjJo`6dU{;OQ-8bZfaHE5$pYE z<9d7~$Kb#{fI1YX<>IF{>bHIl46RUm z=EpsrdfA|fad3`JeBDM`G~zPjV|O^3yVd-oe#^%xx*v(7aP;XbjvtMBoV z*O8)b^_9)5G7Hn_mg^D|xF_FodYlQM{Jxz&98Oz)aZgqPrYAuq`lA;T2h4b)o+rx; zfA5#7u#~*##h~oXeZ$uSxih>~p1VE^th2Tn$FidC^my+oosgUML^EmhIjxfX{%4m8 zN`i7}b%%{fpIPz#Pz*y*3e6c?m+M2c58xZBh47o8mBIJVF8p*>&&=w^^UxtuTD8xs zy+SiF9UMaP8$;qMWd}c8TaY%XyW?H!qzn8ITd`^{um8xKVTr^3w<7u0J{ zr=L@ZeQ5BKUs&??!uh8?K9SO2e28Yg(IPT!9_HYTDnwKZxAq_|I5~E~&57@aS6_y6wkZ z3>6Ww^yYqwIG${{>Fb{tnEXZSv3p7d%qj)@pf=(qf83Xsqe{bzNveBMk z2JQ6~IAy*IAZDQiEju82gN-O>_7DB15J~OfaF$=#<#FbIP`>by@6CB``bMO9JE6me z8gEg2)mXX<0~_QE+0t{#Jy(L(Jtulr*f$QU{XFV)&sO%(qx8nNQ7Q==O$c50Hp~ZX zFLB=QI|D&fJb2RxB?XvK{m*x37S$r@SltM!A9X1y)cYo;;AGHZN51l`My{_Qy|8YJ zp~;qvpS4Ln$G3T2NQ|rbmP`Eh0!YC0j%;A8WoN|3C}T2OKw2IqHb4^4RZMj@V2jAf zp$>4zrYsdCHj(~&CaFDayz<8*0La#NtSefZ?kyhCDpD4c#VCou6#^3$M4 z+fj#ezP=(M=r8-#X{Fy^EQ^M7AJ8>qK=O@2O-{wsb4pbe2@3J|{jI~5P*WBAvj*F| zy{<(mKU z`yI@@A>#a2geGX)SEz_!u$54~01^bX7BMq>#!37wMH~}Knke2b=XKaRHHA(`436o$ z+|rRB{DsGIH}LoKMdedBwbEx*R}mq6Ui}v&;*M*LL86+D0u5qgPYjT%;cvOye$paelCSt>eja0f1>nC)UY{ z`dZw!xK|XWZ+*s)hLsJ-8I__nJq^hR;5|?}`gv%?I{(Xi0^0X9|JLanW@TFrT)$sO zh-K6Fl_$vD%~e;&Yex*4?0S}$<0d%Bh6yz1SmS){M$1a>zccE1Tt8s`tI_|d@u*xQ z`Zb+7W)i+L&=sPQeKwScKK`!yUu?-hG(towOWX_SMAWVQ%tv>3%W*s1GuRUr_dS;~ zOULD_rQZ;r%`xM(PG4QmNF-8zS=Po`7u#D${R9FFq(lfG)VW_EL7Ba!P9diYaS_k@eb z)Ct9hnd|rOc9IgA+M?ewMR*==%))2zPhJPWRK0^_!zvg1ohE=iEJ8Yz-u1{;tCh5M zl94`o6Y@AW0)K_cb)b=4xAxIqxN3nk^WIhI8Kfn0nAld1&=g>y5AX(w9&>+ekQ zJIsWpwyeK+>V}+>>%03<9ld)5+&=;IdwaUn^RJx$SycovO0PAyi#*EQ+$%gW9ntir zjU!@7(CK_6@8)>9NKXF|NzJat(>=%YjL$m`Gq78dg^D{vW(RxxdBvY}%SP|U^eX5h zsALZVgGduNsiuK}2S!&1)-L^j&}hJd9u9FKh0NU8y0_(5D8isl6S%;Ah=7~I+G>hwG;4imfRB2fB-;}lU zHlaCJ{|Ay`_mcQ_aBuE->vNwDLAn79iO+|-&>^=J*mi-Iro`7KbLH3HjH>)7IfU|F zroAy&j*V1MM2QohV2O~mW0B?X%z4(cFp;XR_GRnLQk;siXv0?@f(VKmx8yHL8Or6H z%>VlK-Bo$7YdQ?gMbOit>CyY)12|C@c76lfSd69r2J(izg90c4-^Q))`xPaBM#yrn zGe?L ztfR+Grs&ld93?)b`~$|wJxG-()~(wuZ(pgisu?ty`xwgo^246Jd;Lc%zQP$BSd8O_ z|Ed9Z?sNkZYCjIfq_^KfNaK$2D!Z)qa6CWGxU(l_jiM!z+}U;f*Ie3R|Ig?J{Gyj;ih6xKXns{SD%6@cl^@6-#h~9-H zhm#R&qW7aoFg+phuFnZr2}T^dBZ9R2MO_2@(+$3_Y}VM)`mrUkRXI0LE|W<_$H-~8 z$=XhEG&_kt;L)?*iWe5z*-WADlKBXdO(*1+S>l(n z%i9frDp=4*O-Tkzl02lI=q<~7`bRC~8`t$$EHBG_b*fhnj=m#9OU%%{mg|fK0^i3^ zCJSW@l+u;6NLekA7Vb^|Rw6%rER29)Y*LlgC8&#)KdfZ?OkpJK26B$b_->z4$yx-m>Bzoqgi6l-7+q>SMn5l1n z@JZr&HaSfD8kHIB38fzq@f)~QGx8wlxTL3&N?>Z@_|z11OjXqw=|k0;iV6vB+etL9 zPt@~v8#-MHb&fhZsN^?rb{^SEMDIi29fok!Nz*x|*>{qtoeQyjZ*tpxZl8L1J#VaO zy6o(&pmM&~7Q$aedZx@yoy<#G89;wi?&;=`WvhsHx>kGG*|LL4MG`YM^@b;k5{&2S zc|h_1NF`y(*h*%o!Osj@XSgNQsobj8jtAGE8toq{`F`WdfCp`F-=0Akfi4|E1VrjK z|JHDrH;rFV2$qcG8c}0=eMRBs6UvmMNmVd!>1)FH4v))ts(RlkzIgxeAU%hmdW-TG z)`J}fIRbz6L+#y#I0GF_@Hh0~?%}IGzOYy#81P_4na4i~UNeWH{%wnpFH0yJ*kgH!#t7ZC)k_m zH8dVW1mffEO-;5TI-yp-N_58Nhm2;_4;Obya$RqAiZWRZ9!scPBhS}F&jQTh@~OV_ zX-F_YUe^GAj8a_J7j0w$!%etq#z)5d(Cg<>dVusQU}3Ltgs_yT!~9YC1yNGM+2m~= z9O5)R{U-)L+>9$e%TE|EjkYe{3L+;^mV)(!O&q%q2(sh!81Ily;fM7whJvqwA72nu z7?^&%0O<^z2c12M3;{IdA%yZn%aCfJ2Mr8HVx!izeachJT@JDsG%RSD7Zzeb)fu$s zF^g^O2osIw5N42L5FQAm$)K;Hg*G`kSyiRB!1vLq7l0wSt;jm1VIL z*#2j{ygF;RaKkl=IEK(%bSe~Vv1{krB~(h|p0z~P)u;=pFK8D5ey@~&E0k))IB2E?RSTy$8U%_)v1l(i@icc`4PCljYu_@|u z1&|3SVi65$XlPTw2r=qu$Cy=&30*{w8zU78pp`|X3c1xbULy5NOLH^T0}Jfc4)kx1 zcS@U?l?ETY4Wk@3MYj)C2*c=t6q@I+&jFc&FiuRr;>zu?#OA!DHJB&r-M{)|NS_4!CSPG% z0_nEAqX7=z^~#NmPmMcL+x@NuvI2^yno@$|fn6&V_);V&sHx@8>*-S+v$weE&`E-L zV>F0Z0Z6JVlve>9b~O7j65$;lvvm}*R1)SQf>!|&CdNUaul^Q|t%xUw;P!+OPGFKK zC$wTN30`w^bGP3?PXl}wLhc_!L)13e1<((m`nU}%IHGoh&64z;FMdw3>xiDiM>JyC z6wdV7jz!h1m$Lw~Xsz)rkHW+4#%4lQhC)F>znJfhOT~oS1uY!AYmB%ykDgTpy2j*a z#2~yiwL?ao7r8;x`KrOpiI%oDTQK`ETGI2|OF?m_nr)}rB+PP5v0I_8G|x4c5<<}0 z_6{}+jVrs&a~08&#!E?Q8gf%SXmt!l*Vew&>I6mXd(d+r-#!Sg1@wIA#~;?P3y7o+ zjHC`;d*GlCrQYY%;$o`B*l;JcCA5eGE1ddbWnsBV>Ri{<+zd?v^>yRMjtjqM(IlqM zc?7=)yfH9&QCQ0ye3*wFfHes3h=ZZ8wZ;L>&&6XthlengQj+>OvJ#dKh>E{nE$0WEo7nmEJs&A z{*ucoWiNN8RuDb(*!zB1 z*i0=wSV8{?gI?f?UR%pIEzr`VJTN>ApZqi6zTx4n<(02Lwv`=GokPSB;MO>LKMQ-` zlK={7R*0Czmstq0u#OwOjdPKA{ASeLGryj)V9#H!!>Hb{c7mRmeuE5Lenl z6QFHqr^C2(MMYWWTdsKDFoCF{|5lLR(FWQ;ugkq$AXyiYr@?vQQa5B zu1c)WA}>Bgm(!TKjrqQ&`Brb`4!FWQj5-JoZXrrAatFE&@)rb46^ljk_-0~{e_dF| zxbe+9)EuwnPgIp8cdj}ebFM)q<|PyQT1U9%mMM@E9p8lrcFqMdek)dKbQ) znPMSIHhDbuEb5al@tkTFr{80M5otRN6c81lfJ+Yp?Lf0DQ$%m#RK)MA>-X!1xk(mx zM&8zL_ffCyjo*FT{PTX{*>Lu=$=+*(8e$KOt9h;>9C?*JKDx#GE?)`>FL^#0-{-;^ zEQt(9y4rRbMpGz38$Kifi$y0IMNbC%mfmHt?c1rMGn^cPq^ReUKyu(9p{k~4o$%-= zk3F<#bJ&%ThPue|n3~yOl+|vT>gXiDe#>FPSS>vo=iV>|dZAw6wMSf{a!~)7%uw>m zcjq5V8DG#sP;klGOM;FIgj~UiQu~nF3Uf>>iO|cc>LsOJ_K`avbT%q}qRA&s?-HBW zW*wu^i41oB7qm+5FVeo43TS*MK25u`j4E97UHfQy=x`LT0{N6iiu{6WY@ z+2B6!^}S;RjLdFUP3OynWMwZf^Py~4g6JU#b~xA+I}X(b(4XSUkBWMYF;85QV%tQH zAUmR(AwWfJznTcS$a)0(5`#jEtn$g!c}{46X8e?%@#Es+0wVNyYUF+AheZS@jkzxz z&lsqiYu&I}{CI}mF*Nkd`%|L3jt6fjYjs>TI4?i5e35=RjZVOEls32mSS)dpV`6=) z-ypjJIy+U`))#KQX}KXT)&hH9S=V>6zC{x1>WhH-pB7Y5ca|RqOo(M3|NaHCUofoD zcY-SCj5z>Grv>C1!5ON7HYy;15FY^R_T#z!FeGv(n2FI#(Z8saeM~Qih>Cj31L5OK zM5gQCo4Qo{hlW%jaiStNAPs;HL``CFwgOwmUdl)}v)ZFiZ5CJii0fHz6!Lr+E>+NU zvSRg6UHopoQH8yXdp`q#&E`P$kSQ~d4d@lb3&_!Ye0Sf6X`1-5z1Al^qn;9}p+`a? z0$haP9kAL@BU55|_{NM{V!kUq9-ERPz?N@W zoQdWshI6B3qYC)768~Yew^)cgk~y#+ojF)8-4UafAluL6RI9=O8;cr65xNK^Z1Jd* zgR-Uj1l9%jI!temykYkvp2Ksb@$!tg;I4{gEAZF0nY+{0R@avM3XDD;oNqqavd?4A z`9Z_>##G9$>FE!cJPw1qYE|S8<>lJ|LO5SCD5K4NI}hvE?vHN)Z6c@6g_XI|WPofV zsEC!graz$<({hzy+eoEyH6h$BVbJtDWhq`o7gN~vOwC6=unO_S9I9H-POJ+%zkdCS z4dDk80(wSRNAB&U3FeTJ;J#l9!wm%Zge8kcb%v&2R6KOn4$6fu*i?oqB`J0+#`Cv33wBG-5o z7#xOF>56-3h<}8SD-JpF5U3Ad!ySJ?oJx{1ZhyYvLBsWu;Nc7dQcbYoMrqHoC`twY zp;abZ`ZkusfJD*R3K-T+?Uoru4{5wqNr~F~@P5v@8Q~{4YaH&t+aiETtf?LK3H{9@rqM~y- z44>Nc#c|S)fcD5v?Q_6aH^V4;O=uC0S&;k_sEuZvRySJbaX1>t3VM27|DsGjzaNYb z!;Kf73c7{zx||vtS96bL_qnwoP4Wx0M~kiINTs{*7nZ@Eyi^&_w-PT}LRFXr#qRRh zV-iq~ZS^6WWl*C*Q5yo5vC())+8vBs+$GC=bNJl>*~j!)odhv`TM#O6u5Sr}erUGG z^9B6uoH>Y}0t|vX=DtLs*xfCTln{Aj|FG35viBntD+%7;Do?e4*!o91WUH1PfC>q!4KG+j@PQOcrjc8VF zNCAm2^X$q7QoLwEjnw{KQ6(->#)IHS`(t<7m*)sp?I<8UYU<&t30`(C z(7fnV$YbFTmjJ#6L9hfwd;99vU3YOc!lsK?ig%Uk4q6f%RXB*O>+NSUn! zq2}HbwvZ1p|7!Ywx;+tALUfM%@jP(>N5N@?^%X9zn@%$rAR*f;5U5fA=#Y^{^KL(V z?`$Rc)Y-GiDxR5si$e%;GIkwmAp0!W{b_eSt8se{Cjn5dvyGzls44}S4LLb`Zg16B zr84nm!hCdZ#|D3?Aq@($ebjbZ)^iDY+PrJ9*}D6FNXmHlU4w6ygG*fE7wZk?lgp+!hO5Yy3qII)6Q>H_n|e||`m6cQ9P`*C?GD@i(=#~#K-ki%}e1L)!;$e0PZ z>Hyze-FoTIdy@JLj!zv4*E?_KBpu#y>`t9j!!{4EQ#nulYRu7r44CTK9Kt*NDVCdv zmhSOopP37Zi>j7@yx?7g-y`nOw4VKZLmk{mPYWuTW>PY}}rX)!;u3bG|MvycD2R3&>s8L0C6E zJnC2RvAynyvgI;+>_PqSD}flRKqrVpps1|X$rGyra(84k!WQ0zr*m^EL0=)suMdOU z4D0~9$Fa;L*@syKK#H3kJ%!8D5 z1fl$t+Ap))9m0ZcNxc67TW4XZU0YSe>@hT<3uYq=S9_uCgVAz$`}m-=roPz+iC#E} z!8eGGts1WRSi!IMd`yB{Qx!rQ&YOIb3czR`_t^lKH(;dhMleeES zz`9kw-{lI@6qhz!jUeP0nd~h(Ac;ZgR0lfz`WB0V-*8l;%2*p}O5^W{A#n92LWF^R z?2?7e`AM3wST7~7ac9u%GqQKihc>C{ zT5yYb-YgIb7MNIL;0kapl(q;EpgmKaYUHFxsRNwuh>=S`6JJQ~C)}B?j76jFhnI_U z))?kfEg>tX5>c|!)5Svi1_suxU%#H6y>DpfxyoKc!}Ac*17)$PY8a&k6+QqdPi>?T zE>ubSU&5uVGQ%unt_00y%AX0O^y-=d9P%VJ#8ymw7To3C#AxfWO5x0=(KG5ebUNg8 zr1(#Vt7urgw20C*!LDF!7?r1@4A_UC6FK%dISnHJm*Q&eHzI`hR1};BYp}(3#0P4- z5x3q)u`w|~b?g=r{1LcO`~xWN2l53~o)?A-0;W?39zi~h>K)R8;Q0=p+T50?v zB&+J60kt|H!(F2nfX_%zQa4f!M4n(V%yC6!3*q~f{o9iYkj`U+mv~_^f@SD&S*pxU z4Clcdii}cE!nht^?8l>tT|0MfS5-ZZr)+45lfm9qDFN19sKZc-Y-DAvRr#7oPfrhb z85e*9iJrw>zrhMTm~TEZ<<;ETSq8lf`84(xgf?gn5JZojQFz58$7cHB40jI|6Q>JO zQqS}UZCTt|1}DWP&hKSZxf7V^ko+`m!oFU;5KY!*sSFae0rH4-C@3ghH`9ncTOd;G z;aaOSGHeg22K+26WnRE7zQed~yAWXz_v_$LTT|04e4&HyLANuVuMyG!v(q187GtQV-;!E+Gz)yR_QQSBV5K!U6|?Pw z#NDlO&v~>q{$M6J{oZMKhqS;wSCb6agXwhx_DwNr|5(m17UpDonyv^QnjPJgInG&M{n3{>k7wUOsx`4Ie)|ryhn|h z9#1?O4=^P*4V1N4Be6F#3%-YDRd>ZBV2y{|Omj@1dt?UO5rexNI!=;nKB^l>Xp_cGa6e!r^ixF`Q9Q%05NJs*V{Zldg;T1gly z$!(lj-?HU`R@JAMo8zm^>yk}bBf#Zv)Ou=IJ}O|TlE~RVNY%X%iyM0d>V7n5#N^_C>!lp&+t$r&hN=Ply;Ny zKDSXrXN&p5Ev=9{p)gfKGCe&C;2r3ZUFgF23eL?*XH(A`n@!=gnLbM<2jW(G!}JV8 zk@>_2@Rz8G*DgpE-T`{;EE59wLs(2qPr+9ii3@JOBx%djaG}I{glP&PJ`8|WK;kRC=+X_~G-nGrszF=cqwgN9HuExaMc7~R4pNU)eZY@A! z-yYROB0Wep?@y4R;nTA-GGrgzP?{L_~BC7UQ>gK?kG2W zaQ43(B=}2CSq(vVh2w%;uTtjbW@C+CGzCh9%m+gT+@D7Z!|7XWdjbwg#KeWLLiFuR zzrE=#_D|*5cm8};{~Chx{sGBpD;>~QOFX@vU{DX=G|R07d7d>`CMR7QRSK;u9#n7S z=0=*W$t#OoZiF@Z)O0C>dtsBBv-;k-YZ=}Zl&(`z!%9PX0?lh~%D%o%d6V|j(ief& z_m8kchQl_xV}eZnqwV1TxmjUHcGw4B(1hBVwZ@%arapnvdSPbFt+y6(0ghu6~-O!_;`66 z&P#hw4Zj^(TQx&Nq@LdwMeg3FsH9!5%%xxL(B^a^K4B>CAdB!93*lLY8N1dVpHzUv z#y{fHlvv(r_Y|he6xYX`l|PyUsze|r6H-Iihb4GaK#LQ|%FGx*pPy)6nQxw3{m!(! z?2Mcb`qJ>BdxzOTAY8*H3PQ$8cvyIQSO0330H3V@dRrtj)Fc&lb|W!f&yHXdiM_se&wX6DB9ZNnGakJHL{}2}1<ihLlhoy_}+!!suM9~xA zqv-KTK>ZFHt5xb0WEf~4U$jJC^R(^vbhWNOxm?TZr!2r&+p zvq&U>5NALx?BI}|kq9{=@Rd)J!LChX_RQH`X;5!eA4=|iYLm*KPl|Q8-Sm5B zroUF76a8Za(LR34o2$MTH0NEqEwS)Bsl*U7YZyf+vFS&0c_@ZtTU2!N9BHuei8PN? z5dx)l+kgOy_KXoy@KzHAy>wPj>DkM<=c3Y9DZFvqb4GtzR?eaif8`sC@j_^l_7wFm z7kho&x6j&!s2xifIh~=$0Z+pN_b)cu+Bfir)gN;$s+YdS6Shxd_nTwv2j3j~VRLz9 z!}=c~3?Y{;g`~DG)JY$7`Y`kC=~}}#LO<@@ulhK?;pj|_WyX&-O47{e$gVw(7_kx+ z9{y=~xLII^3pOi2@!%eVne;WtJq-;FU0hsn-a*)c*<$eaj){o@esTA%J*57ann_JV z;B@NZV&Ecb{6<55@|n>(47uHT_o4|eN#_XU#*!iHk(87?aLY(v zzYFty^z@oAMFitks__UBNyle8Z^p;-n%0Ky3z1*%Aa_kq=m?L_o*%F^gPY1O=+EG% zTykvRkt2Q7)(#A9Xf&;@d$2&MozT8MQfy1{g9l_1o4@~JTtcj5edd^7NY0kN{(dx; zPJxbvIvb>hdv9HqfaD?_-(-`A47?g0WPxA`#~3&%&E|JcaUTNnQ@?gA5mF?aj^#7+jCxE;_GqZ}gCn z+M#tC%nZSN7w3$HPF%0Io#fNo$2q0N7;m*u{^OAK{dl!TZwZwVrpB`zn`n)u_V*5~ zX1P4PszZ%-WOsmaE2*xbHj^gdsv^rLKe{{- z?s4l@5@9C?A(DUBc6acOq<;J2LK~`wQL?)mSVAsX6$ofD(D8 z3pkxuY8KmYPAPr4C(dYqLolP#Xn96CM z3nw$G&3~y?(w`=!^fvq@FhOFv|;xCdC_a7p(8sFHTPDA zH{^&J+xC4?)m#i0pd*A=aFFPAk1?^h}gcUhVJ!<7e8h7?pgtN1oX^ zs1shI$Y3+1_2uyq9w*QfF<1+!C`|yqA5OToIJy2Yi!QWxVc??%wg6BJ{W3NF{eNB& zdP2a>NfB20BBHg}pm6XdWE;_u!VCQmj8w`kEj0tD3pBNLPtBvU^<7jVT5}_XB7?Q4AUHjcCCm82HWZg5!g|QHR zWepS4f^Q5pYl2nfT@;d3NJC-|%m{4vi*cg;fs;VR`&Z zcI~Zvd;d)QJ+G$?W#yr4sYYL3$nTVQ=w{M?zsXsFwUUONMz(PI5WyTkOIcyw*OyQ} z%O$;ib@x}{g(EAa)qF$S9ea87+z^ zn@Wj_NOnpJ$&BCiobT)BuX9c(e4gk1zQ=W6*L7q1z?IiDZ_(%9PNBD8CMrDy?)la! zS<_yZ_xq;$;yZ&?la6sSA+@)JK{vKoB>uvI&OE^t##mZ*<=%J+m42!xlp%ZLsa{DLqnTXlNJ}83}LqCGuR{VI|-Xg!*9| z^Y|_pQsEln>gL*{WZm6!@@bHIrdGE1@cvw{h?p46?oxfU7V*-X>^Zi)Vr?aI*Sls{cP0zB24{34hPH^bj#p&z@VH3Zs6=pq{Rq5q+_V)I6 zbd(~63&&x^nA&%u2%G2qjQ)MQV%s^dqG_&<+>23u|07qPlq71xz8vI`T3m3_1fw%2 zEU~**{PEYJp-ymd$xkmQHw%~%J{ly)1p(PIX{V&*KF^^*EHgDWCYL<3tO^~RwjI?> zpWkrg&Z-1kgh8C{N$H7jQ7Rp)HJdz}xD;mDWiGcP0SdfD!sRnY#f&tD`v?X%g*TQh zZI=WiJR=(|hD%#AeXb*5&u|{GramL9%0w6#in`KNtk?O#5yskcI2EU$kX4pnzoeq*cKy4{4ZckHAxP}!mUb@iF7*Nqj!plT% z6UzBs_?knrc(x4G7wi!Pxu^>Wt-HH>|LiZAaPI_(4Tu8i)7j4RLSNKy;54mY%O;Qajb$WjP`6J2qE?Mfm z;lMJNc>#8JK(bFFIgW+Q>R2;@9f!W;EL~;p(U~)6;N^%^Fb))N+qP|o(iDEe1JJ7WPD5B-OECNw==n4_@WO(Fw~}&lE&cen4+=uI#j%SX zKHF`W`D1l=Id(D^AN607N|xv2v0*4C6zImKoJT*?{S8s;du1He!+|=}iUMyZ$3FEdf1;pEy3Zb(!q}Jw2xO z3OIIel67oBFN7#h&auLe*BhD(b^pm@sF%*!VTEOxs(B12l({0O_v&SP@ZXEr^w;Os z?c1!oKWmz}-%Hzzm~W|ITCNlg_>raOEEdT1+F!IGI=xAr)rgDgV>YoHE)<73*z6vZZKgi*Tl=UMX^*z3p$#XR`o47Phx$q*> zgls_D%eHfUMKPXl=C{TK-H-g~eBdXg$vH}hjbUh8Wc}%;3Gs?_3orhU&mCdnW{0G)f>w|9uWpH%INXSc zy51$#A>Y=35Y&~g{lANVZUFsSm1<8#Pj(bg|fDm ze^H#Fu+Gth%Q4@c^QTNrJFzfk-~Rm)_D;FjZinj(BhP_aKfiK|;%*mEO1e|wsnA_xSV=ePAIUTv!yp0q8lCfvmRr|#}h6J~SljVX9!^fjse(@(WLAGMLQ zZgM?4!m|oD2R5+M{T0%gJU&C4_9oXaI6|FRecZND!t;fK)T3QTREe+tq;o;#{`kileP2BNM2hVOxmg3E;#dfx% zM?ZpH$Hpt0Q(5VDkBcB+0Yg)87OaW8;J$|neCPm_(n|01>u?uCv!@l@((xGxGTgYS-ZE)7j8=ZGuDU)b0c?OdEp zb9k_XBes}(_;OhJzp#A`g`X#jk}OOr)brcEdU;H#D@=$?nd?hAa2>HG#NH<{#jdNM zyzYv2O#dbOHzckn*3a@=I+=)M8|HZLwd-_djR+-o&ju`o<%j`Q(59DXIxPbxSTPa_wQ%G}$W3xo$_d5Bwr=p#X<- zhQXdjQCN5g3(UqIC4GTH4U=@~rpJ#TgZirI_F6d7)81aV2RSQzsSJ}L{Bq?GeYr{u>mqQLcvhenGECOrN4eAXp-m z3#%eMmDW>h69;|cYnkJPBElkPPp~Ue6JJNHn#Lp8>Utsa`WW6d&xSml!^vwl0>1yjoBojQGtln9rrXVgu&oK|k7Mh@?A-1B=*hUHDkya zX?)7{_)LMzC-o%Y{v}u{On{Y}1tKK5DH*0;pb%qejGi8d(QiN}(vhgftLIbLTGHO9 zymxPMlrfjNx^P_VGb?l!*R;bj*V-ByM$lMqUyaJf*7lB8|$nBl*$UlP3BBJr=2Ry-m9r@6Ebqp&h zam`Wi$0dnk9b-M-{c6+bwl3ATd1bp+%4UoRqA9&xwXN$GP5#S!UTd4q%H8~CaSM;lPPqU+T?9eNrgc_BbdR!me+b0s z8rkaUy*iB4q7_}x+SMCnuuQ}Y{@`xOIoUdECAD0^D#b)asL47m^EuQ|-@+4Z^Sk01sGxvg^LzK`}1u<(Wh& z6CE8*utRq@K7R6(Q=b{PDLO3TEq3%&Oh?O#jM|<(>x*#>5<4}<;a31-{72OXbXJN` zk1-{XGYiQ(sQ**`oe?`$@)tkE@fmhGI0bNNCdRfpIu1QR)9ePn|DV0seV*_WZU=H4_t#f(Xn7!J6Ocdn+q{J_~C!kO2F!_mK$IAekiy zt3j&Y_M_v38#7b|!kXP!POAzFtYZ(BYX@g4ySCKj5d>pD<=zjS4=Ji+`_*-X^DhvV zku*fzF$AmRCyzUCvNu|AG&x;st#N7H_Ho>6jX|>XryRAXL(>)RVJ;~T@3v28X-#xL zvxH}f6<#^OnfF1cX`0^w)hBQ9sOj>JJ(~zM+h(OZPOk#=e*Rt+Y8vA)JI6s#f2}O$ zwVJxocIi@;sS-xXAbsl`_zG%Lx~ku5y>%>Ufr8N{eHuS{E-qHWSCP85S0nec&76^`VDay!!n> zrZJdLRQ^Fhbg$1J?)uC~dt>8Hy|#MZzcstKm{5aX!4MO_16n3MQBG`@P1L)co{k#n zBF2tj8f_{ZIDZ;!N|F7FDj=ZG`^_S1V_{A=b^-U};>k2QJ3GU6lOJRis%4LxpTVug z0;7{oPK};@;0=>ju|*{sHQZ@U&t0Or=LsTgA8$y4j#Y;0*guC?l+Ua!UYiI)s!5r- zO!y}L8*mEy(XCQfH!b(w(f=7dy0>f(y+^^Sq5-Sund>gsMDjO_ zfML)HV(SY*u3^PPsCZE1uh7317Z6a7SPMB?0K1;9?gY3sZf=r(>`s#`85ax0L4_I~ z9VJ_QG=0+!SLEd7fv0`{-?E=$CC-)`g+$pIb4&~1fUMc|<5gRmaLN=CXhL@zmu3yQ z4F|T()C7pv3n1Yw*!{!ei+m8B0Z)=>!z<}X3rPh<#nMoI#d2~Zr7gu7FK30KX~G>d zT#1))-==npgCrLdvc;GCC-$!1Wh03;oMDdOCJ_i@!MM^tj$|0SH+!C@Iq;92@GsX` zzdbHr%C9A)-lFttEM>feuxiLl4=wLK{0RacW69JdP#PjqNR+Zz2()g2H$eR7C1}0{WVEBJRSkbYI z1u5kM<}R|Y#-y-(&JHZ~2WH|W-J2{EX;@gF>w6>7u5L6*_ncf(%fnr$7z_Tz7Hp1s z6%ufFqv1I+db_{yRG#%5JV3BeyXJjZ)k_?sC)B8$>{%+(nF!*T)YWZA2F6(n?f~<| zQPWL*hV?X<7Anff46-fV1;B_@^Z{)%p>ycamVK9{e_9+aF}oj8K3|Mu$r(mVpb4_d zjX3Ij@+62izlg6>Q`W#7$dGADOZ_60Lxd27kL8UiuNp;s-|fwlh_`7eDbw{_n&xGn zK7B%r=H}sHroDk|=8ea=!jVX*pTg81ErkeOG(i0HjEoKejhf(ywFbK5R77lO`9E&m zP1D+D^g1^t|N1}NOxV7bQ^=LGrSpqCV7t?!%7UZqxl6~kDo46-HK+tZTauEB7P!9$yH=!|2mmi~rmJaAJ$QUUAt3{3tDxT4WnB%g*wJ5F zCf(*>mFAl6AS7*`TjiQg*PAz!#by&C-RZcq;a57Oo5%{Yc>q@(YjdJivpR8eu>&)w z$pl3PYK7Ak%TZJf9ARgC`6b&Z27Q^}FsvH~xZ_#IpZHYjNe&fazLwLD|xRrSzE$gug;!`<+&{qjEUH!aSLId3g|FlvPv=#pLgXfd!JOB{MTKaSP}L zta2Ab#6-+XjC9GW>Q50yeB??od_oMN2to+k&&4WE9TQ~V2O2SCr>+p-pk>6q9L&zb ztQ{y?Snu0tUts2fEC2}?_C>h6+dR8?1|lY;v-b~`ocgePhM%9G5L8iFMFp_p!5xQb zJ-MER`yKLFO!DsX@b zdE&YRE#asTD^oQ2nl1KiqNfs7BpuN_D)plA=%UD~1c2zPmA1^uopFXoXSBD~#FZVAhcUcZc-E?f)Fqj+6vE%}Hl^sazxbRd?K(F~ zvo!lBDoivs^GprL4n*=&s;Y zEk4K4c2ycOh)#jO7~>)j422Zkz{m)7F*kt`J6bP>{#md;Sin1Z^yr`KH&=>7l9T+V zD7{<^#<9G#<;@#X+l@nnJ_1?86v{_TGn_Oqb<#bom#8-f*!g$8IaSjF{sC*U7&Bo% zb-$vUE>A3*hmY?WwtWz>dbuPu^wq58c?_Hns;g6iUr;(yX(Y!mcM$jH1(iCsetHCoTQkj|arJfAxCqknrQ&F4ZyFT0l=kHTQFMM;WcJGqIl0 zj+1PH4_?&0jkiQZGxN6X-fUILLlCX8{fE>{S3}zv!um|2+*=fqmsP!Ns-I#fC9Le0%>5{S&Ah?s*}e}ag8`7N`_ol;WPl^%A95`7}c z?b{7g8$8o>M48sFA9H#JJOSHcT3kl`FtO1EjykTYZ=y9$9?!l?8G;{70aTwW9=VsG z=RvVV<&R+juJEE=c$WAlC?>G{lFAmD6)^2ch(r;F?AOTE^>>ZM5ZbIbPIwoI|CErr zN>4B#>fF~h-yYR&;mi86m++4e@pxzAyRf;8USgM*@_<+Au?HQuJ*R}I5`b^SN{faYGNpKMiSG^&-=Z_h;Ry4ov5&mC2 z55`B^JNI8KBqu6hNznL16~`yn1@Il&7veq98pVOvU%vRhPW`?RXJ~#fr;m8>9_l62=)(luR^Bl6L}rN zT{crS@e}8inbi{N1Z=x3l31CT;@d}A>Wh*7K7LHbZ$>J#U_VM=Q*rSz&854hO_9z| zU$nQog8smm385q+^3lw{=l@4T99KFdo`~)~@cgH%+2FEa=J!{?H6FV}@gH;A4BhVK z&ybQ+IA1dm@N)x-ITj*O4$*B$a4@x+npz$B9seDoc&%(pEOqR;v-i?j^OTOP;jyvD zkcg5S8zEqmSGC&j8DurJMqo*+qX4x|i-2*Jkf(^~4{%O5Ne(ndB-ObTD>XW?j9b%{ zwLVw6#A~bU7uk?ez8L2DRM;cT!R@2Sp!NzCZWdQA&-(FeZ2nY%k^joYk1{fcrN^FD zt%w>mpI9>S12&G~5)1cl_s1RsxnOXy3*{b_}bdX(Pz}T_1{h2^hD2 zm$H#iMoarY3NCdWT4)S58w5<4>ZQ>^VP<^@Anii=M^sMW7|3IX@71fzh{*lb-iM{u zK5u2$l(e)5l6&o51{PLC?@(6u?x}F1I0U{0=q2I3VPCkc3MdUZ9J!Ak z%|XA^#P(ymgoK2+_;+xcFbqWBTmzea*vP;5r8YnDzWM$84FqIzkPR#hx-gtubk$$7 zPg$?7zTLNa=AgQxd|G<9!(w!8TMXg=y# zH|;y{Q|bEbPDf>4-Bc=v>5l~3v~XE=$$>EW7%C#=ymox>N|`SgQzNiyAdp;OnQq*8 z5t7uvK-x#gUJKmgq3^z>%@lDi=vf=TQPl1GDT}bl(iOi>VQNy^x$`Iah|#9uIzSE~ zpmOchZ{B>_lt|HU-7a&Gud?`FG&GmG^^)Sizh%pz=R&e6l33L(aQ_ z->UvihUUCKZ&j#%ZfM}X-MzoaXDY`>Skz|onvoaHq33+Aax(YC=k}1N)Lcf1d%41} zmFmfFY1Kl8BqvO1r?5BE#YLQpsa5x;jwoE)A$#)1kj0PTUm5Rk%h4BOCF9MJX1>19 zEhOm)5bN>q*?t_zHI0J87J^^Sut3pDTI%bh$e{EWMraG!bBO!u71rz5uLpa7#lw7C zay#n1mMR|)4{24ae{T)uZODb%d-i~BimjuFBdsXrTJHH{w{Xt`zY`rReEiL%(hf>1 zQFof#XsNJldHTvRYJX#~NepaIfMlrnwE67}FI)m&@c6@12Yf$6x~894Xo@}uAjMiC z+z8H|l~OhEVch-xLT(3AZz6U;=Q7=1ZTRzP_BIB7;A?#ikv)qsGGi7MLy5eoEdcbJG8p2cAty$UOxt%Q2jZPo}DkZJWe0Dk>uI z?b2MZ!4!NZ+xz=Z%qOTbCax-DX(OCDQ-)c*pUQ%8I5Pwq_GojVZ0f{VX3A8pSYjLc zhXk8wTnZj;ZZrYwCnhE^za#wFQ)L{q7QZac{~8!S5ga#8S}Y@~BHmO_8^6P=0pDU| ze4OvMeOi`EC2R;FohR5CX@T4nrq_OvY=;=#zb`0BFBb&H6^a@X7h_C+1bWo;BnCrsLsKi`-;P|ckkzS@8VX@lV<}=xhktS zo_+9eS6N+fIH4lGd-u)Sds|S0YA$^S)zkb<(dtZQ%GoWc6H+3ubvyd|DbP`-pc|?~ z2P2}QN)F#!zbN2~fjJ__3dhOPJkugFM4xa(eEq~E93&rUWYNDLHkDaM=x2I0rKSI_bz8^4S zV^BR}jhwTs$_>c^^}V%OfmyusH1QU*9&0lJu;fLLa?sGTd~Ggddu+UHmghs=JO1XD z{3~_d=;51d8kxqi0!3kAt@y+XyG#!8rzu$AB^Xief17j49OFI8)pVNk!h>o+5@O#2 z-l|;c#ygagIJE2@QM#H9gRD$FK2}Nu|K2)VNd6zy-(v2u!2%0D$ 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 diff --git a/packages/go/analysis/post.go b/packages/go/analysis/post.go index cd016b4d031..b414e7c8e45 100644 --- a/packages/go/analysis/post.go +++ b/packages/go/analysis/post.go @@ -23,6 +23,7 @@ import ( "sort" "strings" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/bhlog/level" "github.com/specterops/bloodhound/packages/go/bhlog/measure" @@ -47,20 +48,6 @@ func statsSortedKeys(value map[graph.Kind]int) []graph.Kind { return kinds } -func atomicStatsSortedKeys(value map[graph.Kind]*int32) []graph.Kind { - kinds := make([]graph.Kind, 0, len(value)) - - for key := range value { - kinds = append(kinds, key) - } - - sort.Slice(kinds, func(i, j int) bool { - return kinds[i].String() > kinds[j].String() - }) - - return kinds -} - type PostProcessingStats struct { RelationshipsCreated map[graph.Kind]int RelationshipsDeleted map[graph.Kind]int @@ -114,59 +101,15 @@ func (s PostProcessingStats) LogStats() { } } -//These were created for the new composition method. It was scrapped for the current initiative, but will be useful later -//type CompositionInfo struct { -// CompositionID int64 -// EdgeIDs []graph.ID -// NodeIDs []graph.ID -//} -// -//func (s CompositionInfo) HasComposition() bool { -// return len(s.EdgeIDs) > 0 || len(s.NodeIDs) > 0 -//} - -// -//func (s CompositionInfo) GetCompositionEdges() model.EdgeCompositionEdges { -// edges := make(model.EdgeCompositionEdges, len(s.EdgeIDs)) -// for i, edgeID := range s.EdgeIDs { -// edges[i] = model.EdgeCompositionEdge{ -// PostProcessedEdgeID: s.CompositionID, -// CompositionEdgeID: edgeID.Int64(), -// } -// } -// -// return edges -//} - -//func (s CompositionInfo) GetCompositionNodes() model.EdgeCompositionNodes { -// edges := make(model.EdgeCompositionNodes, len(s.EdgeIDs)) -// for i, nodeID := range s.NodeIDs { -// edges[i] = model.EdgeCompositionNode{ -// PostProcessedEdgeID: s.CompositionID, -// CompositionNodeID: nodeID.Int64(), -// } -// } -// -// return edges -//} - -type CreatePostRelationshipJob struct { - FromID graph.ID - ToID graph.ID - Kind graph.Kind - RelProperties map[string]any - //CompositionInfo CompositionInfo -} - type DeleteRelationshipJob struct { Kind graph.Kind ID graph.ID } -func DeleteTransitEdges(ctx context.Context, db graph.Database, baseKinds graph.Kinds, targetRelationships graph.Kinds) (*AtomicPostProcessingStats, error) { +func DeleteTransitEdges(ctx context.Context, db graph.Database, baseKinds graph.Kinds, targetRelationships graph.Kinds) (*post.AtomicPostProcessingStats, error) { var ( relationshipIDs []graph.ID - stats = NewAtomicPostProcessingStats() + stats = post.NewAtomicPostProcessingStats() operationName = fmt.Sprintf("Delete %v post-processed relationships", strings.Join(targetRelationships.Strings(), ", ")) ) diff --git a/packages/go/analysis/post/job.go b/packages/go/analysis/post/job.go new file mode 100644 index 00000000000..491382a2906 --- /dev/null +++ b/packages/go/analysis/post/job.go @@ -0,0 +1,28 @@ +// 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 post + +import "github.com/specterops/dawgs/graph" + +// EnsureRelationshipJob is an asynchronous graph assertion. If the edge does not +// exist in the graph between the from and to node IDs with the given kind then +// the edge added to a batch creation process to be pushed down to the database. +type EnsureRelationshipJob struct { + FromID graph.ID + ToID graph.ID + Kind graph.Kind + RelProperties map[string]any +} diff --git a/packages/go/analysis/post/sink.go b/packages/go/analysis/post/sink.go new file mode 100644 index 00000000000..484307f9ef3 --- /dev/null +++ b/packages/go/analysis/post/sink.go @@ -0,0 +1,230 @@ +// 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 post + +import ( + "context" + "log/slog" + "runtime" + "sync" + "time" + + "github.com/specterops/bloodhound/packages/go/analysis/delta" + "github.com/specterops/bloodhound/packages/go/bhlog/attr" + "github.com/specterops/bloodhound/packages/go/graphschema/common" + "github.com/specterops/bloodhound/packages/go/metrics" + "github.com/specterops/bloodhound/packages/go/trace" + "github.com/specterops/dawgs/graph" + "github.com/specterops/dawgs/util/channels" +) + +var ( + postOperationsVec = metrics.CounterVec("post_processing_ops", "analysis", map[string]string{}, []string{ + "kind", + "operation", + }) +) + +func newPropertiesWithLastSeen() *graph.Properties { + newProperties := graph.NewProperties() + newProperties.Set(common.LastSeen.String(), time.Now().UTC()) + + return newProperties +} + +// FilteredRelationshipSink is an asynchronous graph relationship writer that ensures only new relationships are +// inserted and removes unused ones. It uses a delta tracker to track changes between graphs and avoids reinserting +// existing edges. Any edge not visited during processing is treated as obsolete and will be deleted after the +// operation completes. +type FilteredRelationshipSink struct { + operationName string + db graph.Database + edgeTracker *delta.Tracker + jobC chan EnsureRelationshipJob + stats AtomicPostProcessingStats + wg sync.WaitGroup +} + +// NewFilteredRelationshipSink creates a new filtered relationship sink initialized with a given database, delta tracker, and operation name. +func NewFilteredRelationshipSink(ctx context.Context, operationName string, db graph.Database, deltaSubgraph *delta.Tracker) *FilteredRelationshipSink { + newSink := &FilteredRelationshipSink{ + db: db, + edgeTracker: deltaSubgraph, + operationName: operationName, + jobC: make(chan EnsureRelationshipJob), + stats: NewAtomicPostProcessingStats(), + } + + newSink.start(ctx) + return newSink +} + +// insertWorker processes incoming jobs by inserting them into the database using batch operations. It uses common properties +// (with last seen timestamp) and applies custom relationship properties if provided. +func (s *FilteredRelationshipSink) insertWorker(ctx context.Context, commonProps *graph.Properties, insertC chan EnsureRelationshipJob) { + if err := s.db.BatchOperation(ctx, func(batch graph.Batch) error { + for { + if nextJob, shouldContinue := channels.Receive(ctx, insertC); !shouldContinue { + break + } else { + relProps := commonProps + + if len(nextJob.RelProperties) > 0 { + relProps = commonProps.Clone() + + for key, val := range nextJob.RelProperties { + relProps.Set(key, val) + } + } + + if err := batch.CreateRelationshipByIDs(nextJob.FromID, nextJob.ToID, nextJob.Kind, relProps); err != nil { + slog.Error("Create Relationship Error", slog.String("err", err.Error())) + } + + s.stats.AddRelationshipsCreated(nextJob.Kind, 1) + + postOperationsVec.With(map[string]string{ + "kind": nextJob.Kind.String(), + "operation": "edge_insert", + }).Add(1) + } + } + + return nil + }); err != nil { + slog.Error("FilteredRelationshipSink Error", attr.Error(err)) + } +} + +// deltaFilterWorker filters out duplicate edges before they reach the insert worker. It checks whether +// an edge has already been tracked in the delta subgraph; if not, it forwards it to the insert channel. +func (s *FilteredRelationshipSink) deltaFilterWorker(ctx context.Context, filterC, insertC chan EnsureRelationshipJob) { + for { + nextJob, shouldContinue := channels.Receive(ctx, filterC) + + if !shouldContinue { + break + } + + if !s.edgeTracker.HasEdge(nextJob.FromID.Uint64(), nextJob.ToID.Uint64(), nextJob.Kind) { + if !channels.Submit(ctx, insertC, nextJob) { + break + } + } else { + postOperationsVec.With(map[string]string{ + "kind": nextJob.Kind.String(), + "operation": "filtered", + }).Add(1) + } + } +} + +// deleteMissingEdges removes any lingering edges that were not part of the current operation. This ensures +// that only valid relationships remain after the sink completes its work. +func (s *FilteredRelationshipSink) deleteMissingEdges(ctx context.Context) error { + deletedEdges := s.edgeTracker.Deleted() + + defer trace.Method(ctx, "FilteredRelationshipSink", "deleteMissingEdges", slog.Int("num_edges", len(deletedEdges)))() + + if err := s.db.BatchOperation(ctx, func(batch graph.Batch) error { + for _, deletedEdge := range deletedEdges { + if err := batch.DeleteRelationship(graph.ID(deletedEdge)); err != nil { + return err + } + } + + return nil + }); err != nil { + return err + } + + postOperationsVec.With(map[string]string{ + "kind": "all", + "operation": "edge_delete", + }).Add(float64(len(deletedEdges))) + + return nil +} + +// worker is the main goroutine responsible for managing the entire lifecycle of the sink. It +// coordinates between filtering, insertion, and deletion phases and handles shutdown gracefully. +func (s *FilteredRelationshipSink) worker(ctx context.Context) error { + defer trace.Method(ctx, "FilteredRelationshipSink", "worker", slog.String("operation", s.operationName))() + defer s.wg.Done() + + var ( + filterC = make(chan EnsureRelationshipJob) + insertC = make(chan EnsureRelationshipJob) + filterWG sync.WaitGroup + insertWG sync.WaitGroup + ) + + insertWG.Add(1) + + go func() { + defer insertWG.Done() + s.insertWorker(ctx, newPropertiesWithLastSeen(), insertC) + }() + + // FIXME: Really, really need a better CPU heuristic or config value + for workerID := 0; workerID < runtime.NumCPU()/2+1; workerID += 1 { + filterWG.Add(1) + + go func(workerID int) { + defer filterWG.Done() + s.deltaFilterWorker(ctx, filterC, insertC) + }(workerID) + } + + for { + if nextJob, shouldContinue := channels.Receive(ctx, s.jobC); !shouldContinue { + break + } else if !channels.Submit(ctx, filterC, nextJob) { + break + } + } + + close(filterC) + filterWG.Wait() + + close(insertC) + insertWG.Wait() + + // Remove any lingering edges after the operation completes + return s.deleteMissingEdges(ctx) +} + +// Stats returns a pointer to the atomic statistics structure tracking processed relationships. +func (s *FilteredRelationshipSink) Stats() *AtomicPostProcessingStats { + return &s.stats +} + +// start begins execution of the sink's main worker loop. +func (s *FilteredRelationshipSink) start(ctx context.Context) { + s.wg.Add(1) + go s.worker(ctx) +} + +// Submit submits a new job to be processed by the sink. +func (s *FilteredRelationshipSink) Submit(ctx context.Context, nextJob EnsureRelationshipJob) bool { + return channels.Submit(ctx, s.jobC, nextJob) +} + +// Done signals the end of processing and waits for all workers to complete. +func (s *FilteredRelationshipSink) Done() { + close(s.jobC) + s.wg.Wait() +} diff --git a/packages/go/analysis/post/stats.go b/packages/go/analysis/post/stats.go new file mode 100644 index 00000000000..c00079b5dcb --- /dev/null +++ b/packages/go/analysis/post/stats.go @@ -0,0 +1,121 @@ +// 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 post + +import ( + "fmt" + "log/slog" + "sort" + "sync" + "sync/atomic" + + "github.com/specterops/bloodhound/packages/go/bhlog/level" + "github.com/specterops/dawgs/graph" +) + +func atomicStatsSortedKeys(value map[graph.Kind]*int32) []graph.Kind { + kinds := make([]graph.Kind, 0, len(value)) + + for key := range value { + kinds = append(kinds, key) + } + + sort.Slice(kinds, func(i, j int) bool { + return kinds[i].String() > kinds[j].String() + }) + + return kinds +} + +type AtomicPostProcessingStats struct { + RelationshipsCreated map[graph.Kind]*int32 + RelationshipsDeleted map[graph.Kind]*int32 + mutex *sync.Mutex +} + +func NewAtomicPostProcessingStats() AtomicPostProcessingStats { + return AtomicPostProcessingStats{ + RelationshipsCreated: make(map[graph.Kind]*int32), + RelationshipsDeleted: make(map[graph.Kind]*int32), + mutex: &sync.Mutex{}, + } +} + +func (s *AtomicPostProcessingStats) AddRelationshipsCreated(kind graph.Kind, numCreated int32) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if val, ok := s.RelationshipsCreated[kind]; !ok { + s.RelationshipsCreated[kind] = &numCreated + } else { + atomic.AddInt32(val, numCreated) + } +} + +func (s *AtomicPostProcessingStats) AddRelationshipsDeleted(kind graph.Kind, numDeleted int32) { + s.mutex.Lock() + defer s.mutex.Unlock() + + if val, ok := s.RelationshipsDeleted[kind]; !ok { + s.RelationshipsDeleted[kind] = &numDeleted + } else { + atomic.AddInt32(val, numDeleted) + } +} + +func (s *AtomicPostProcessingStats) Merge(other *AtomicPostProcessingStats) { + s.mutex.Lock() + defer s.mutex.Unlock() + + for key, value := range other.RelationshipsCreated { + if val, ok := s.RelationshipsCreated[key]; !ok { + s.RelationshipsCreated[key] = value + } else { + atomic.AddInt32(val, *value) + } + } + + for key, value := range other.RelationshipsDeleted { + if val, ok := s.RelationshipsDeleted[key]; !ok { + s.RelationshipsDeleted[key] = value + } else { + atomic.AddInt32(val, *value) + } + } +} + +func (s *AtomicPostProcessingStats) LogStats() { + // Only output stats during debug runs + if level.GlobalAccepts(slog.LevelDebug) { + return + } + + slog.Debug("Relationships deleted before post-processing:") + + for _, relationship := range atomicStatsSortedKeys(s.RelationshipsDeleted) { + if numDeleted := int(*s.RelationshipsDeleted[relationship]); numDeleted > 0 { + slog.Debug(fmt.Sprintf(" %s %d", relationship.String(), numDeleted)) + } + } + + slog.Debug("Relationships created after post-processing:") + + for _, relationship := range atomicStatsSortedKeys(s.RelationshipsCreated) { + if numCreated := int(*s.RelationshipsCreated[relationship]); numCreated > 0 { + slog.Debug(fmt.Sprintf(" %s %d", relationship.String(), numCreated)) + } + } +} diff --git a/packages/go/analysis/post_operation.go b/packages/go/analysis/post_operation.go index 6d8b036f9a2..b0764e6a729 100644 --- a/packages/go/analysis/post_operation.go +++ b/packages/go/analysis/post_operation.go @@ -18,67 +18,61 @@ package analysis import ( "context" - "fmt" - "log/slog" - "sync" - "sync/atomic" "time" - "github.com/specterops/bloodhound/packages/go/bhlog/attr" - "github.com/specterops/bloodhound/packages/go/bhlog/level" - "github.com/specterops/bloodhound/packages/go/bhlog/measure" + "github.com/specterops/bloodhound/packages/go/analysis/post" "github.com/specterops/bloodhound/packages/go/graphschema/common" + "github.com/specterops/bloodhound/packages/go/trace" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" ) +func NewPropertiesWithLastSeen() *graph.Properties { + newProperties := graph.NewProperties() + newProperties.Set(common.LastSeen.String(), time.Now().UTC()) + + return newProperties +} + type StatTrackedOperation[T any] struct { - Stats AtomicPostProcessingStats + Stats post.AtomicPostProcessingStats Operation *ops.Operation[T] } -func NewPostRelationshipOperation(ctx context.Context, db graph.Database, operationName string) StatTrackedOperation[CreatePostRelationshipJob] { - operation := StatTrackedOperation[CreatePostRelationshipJob]{} +func NewPostRelationshipOperation(ctx context.Context, db graph.Database, operationName string) StatTrackedOperation[post.EnsureRelationshipJob] { + operation := StatTrackedOperation[post.EnsureRelationshipJob]{} operation.NewOperation(ctx, db) - operation.Operation.SubmitWriter(func(ctx context.Context, batch graph.Batch, inC <-chan CreatePostRelationshipJob) error { - defer measure.ContextMeasure( - ctx, - slog.LevelInfo, - operationName, - attr.Namespace("analysis"), - attr.Function("NewPostRelationshipOperation"), - attr.Scope("routine"), - )() - - var ( - relProp = NewPropertiesWithLastSeen() - ) + operation.Operation.SubmitWriter(func(ctx context.Context, batch graph.Batch, inC <-chan post.EnsureRelationshipJob) error { + defer trace.Function(ctx, "PostRelationshipOperation")() + + relProp := NewPropertiesWithLastSeen() for nextJob := range inC { + relProps := relProp + if len(nextJob.RelProperties) > 0 { - tempRelProp := relProp.Clone() + relProps = relProp.Clone() + for key, val := range nextJob.RelProperties { - tempRelProp.Set(key, val) - } - if err := batch.CreateRelationshipByIDs(nextJob.FromID, nextJob.ToID, nextJob.Kind, tempRelProp); err != nil { - return err - } - } else { - if err := batch.CreateRelationshipByIDs(nextJob.FromID, nextJob.ToID, nextJob.Kind, relProp); err != nil { - return err + relProps.Set(key, val) } } + if err := batch.CreateRelationshipByIDs(nextJob.FromID, nextJob.ToID, nextJob.Kind, relProps); err != nil { + return err + } + operation.Stats.AddRelationshipsCreated(nextJob.Kind, 1) } return nil }) + return operation } func (s *StatTrackedOperation[T]) NewOperation(ctx context.Context, db graph.Database) { - s.Stats = NewAtomicPostProcessingStats() + s.Stats = post.NewAtomicPostProcessingStats() s.Operation = ops.StartNewOperation[T](ops.OperationContext{ Parent: ctx, DB: db, @@ -90,90 +84,3 @@ func (s *StatTrackedOperation[T]) NewOperation(ctx context.Context, db graph.Dat func (s *StatTrackedOperation[T]) Done() error { return s.Operation.Done() } - -type AtomicPostProcessingStats struct { - RelationshipsCreated map[graph.Kind]*int32 - RelationshipsDeleted map[graph.Kind]*int32 - mutex *sync.Mutex -} - -func NewAtomicPostProcessingStats() AtomicPostProcessingStats { - return AtomicPostProcessingStats{ - RelationshipsCreated: make(map[graph.Kind]*int32), - RelationshipsDeleted: make(map[graph.Kind]*int32), - mutex: &sync.Mutex{}, - } -} - -func (s *AtomicPostProcessingStats) AddRelationshipsCreated(kind graph.Kind, numCreated int32) { - s.mutex.Lock() - defer s.mutex.Unlock() - - if val, ok := s.RelationshipsCreated[kind]; !ok { - s.RelationshipsCreated[kind] = &numCreated - } else { - atomic.AddInt32(val, numCreated) - } -} - -func (s *AtomicPostProcessingStats) AddRelationshipsDeleted(kind graph.Kind, numDeleted int32) { - s.mutex.Lock() - defer s.mutex.Unlock() - - if val, ok := s.RelationshipsDeleted[kind]; !ok { - s.RelationshipsDeleted[kind] = &numDeleted - } else { - atomic.AddInt32(val, numDeleted) - } -} - -func (s *AtomicPostProcessingStats) Merge(other *AtomicPostProcessingStats) { - s.mutex.Lock() - defer s.mutex.Unlock() - - for key, value := range other.RelationshipsCreated { - if val, ok := s.RelationshipsCreated[key]; !ok { - s.RelationshipsCreated[key] = value - } else { - atomic.AddInt32(val, *value) - } - } - - for key, value := range other.RelationshipsDeleted { - if val, ok := s.RelationshipsDeleted[key]; !ok { - s.RelationshipsDeleted[key] = value - } else { - atomic.AddInt32(val, *value) - } - } -} - -func (s *AtomicPostProcessingStats) LogStats() { - // Only output stats during debug runs - if level.GlobalAccepts(slog.LevelDebug) { - return - } - - slog.Debug("Relationships deleted before post-processing:") - - for _, relationship := range atomicStatsSortedKeys(s.RelationshipsDeleted) { - if numDeleted := int(*s.RelationshipsDeleted[relationship]); numDeleted > 0 { - slog.Debug(fmt.Sprintf(" %s %d", relationship.String(), numDeleted)) - } - } - - slog.Debug("Relationships created after post-processing:") - - for _, relationship := range atomicStatsSortedKeys(s.RelationshipsCreated) { - if numCreated := int(*s.RelationshipsCreated[relationship]); numCreated > 0 { - slog.Debug(fmt.Sprintf(" %s %d", relationship.String(), numCreated)) - } - } -} - -func NewPropertiesWithLastSeen() *graph.Properties { - newProperties := graph.NewProperties() - newProperties.Set(common.LastSeen.String(), time.Now().UTC()) - - return newProperties -} diff --git a/packages/go/bhlog/attr/attr.go b/packages/go/bhlog/attr/attr.go index dda1e19adbc..b028c19b150 100644 --- a/packages/go/bhlog/attr/attr.go +++ b/packages/go/bhlog/attr/attr.go @@ -16,7 +16,10 @@ // attr supplies custom slog.Attr constructors package attr -import "log/slog" +import ( + "log/slog" + "time" +) // Error consistently includes an error message via standard logging in the "err" field. func Error(value error) slog.Attr { @@ -44,3 +47,27 @@ func Scope(value string) slog.Attr { func Function(value string) slog.Attr { return slog.String("fn", value) } + +func Operation(operation string) slog.Attr { + return slog.String("operation", operation) +} + +func Enter() slog.Attr { + return slog.String("state", "enter") +} + +func Exit() slog.Attr { + return slog.String("state", "exit") +} + +func Elapsed(duration time.Duration) slog.Attr { + return slog.Duration("elapsed", duration) +} + +func ElapsedSince(then time.Time) slog.Attr { + return Elapsed(time.Since(then)) +} + +func Measurement(id uint64) slog.Attr { + return slog.Uint64("measurement", id) +} diff --git a/packages/go/graphschema/azure/azure.go b/packages/go/graphschema/azure/azure.go index a3e05d7ae5d..822970b8bd9 100644 --- a/packages/go/graphschema/azure/azure.go +++ b/packages/go/graphschema/azure/azure.go @@ -429,7 +429,7 @@ func PathfindingRelationships() []graph.Kind { return []graph.Kind{AvereContributor, Contributor, GetCertificates, GetKeys, GetSecrets, HasRole, MemberOf, Owner, RunsAs, VMContributor, AutomationContributor, KeyVaultContributor, VMAdminLogin, AddMembers, AddSecret, ExecuteCommand, GlobalAdmin, PrivilegedAuthAdmin, Grant, GrantSelf, PrivilegedRoleAdmin, ResetPassword, UserAccessAdministrator, Owns, CloudAppAdmin, AppAdmin, AddOwner, ManagedIdentity, AKSContributor, NodeResourceGroup, WebsiteContributor, LogicAppContributor, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToADUser, AZRoleEligible, AZRoleApprover, Contains} } func PostProcessedRelationships() []graph.Kind { - return []graph.Kind{AddSecret, ExecuteCommand, ResetPassword, AddMembers, GlobalAdmin, PrivilegedRoleAdmin, PrivilegedAuthAdmin, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToADUser, AZRoleApprover} + return []graph.Kind{AddSecret, ExecuteCommand, AZMGAddMember, AZMGAddOwner, AZMGAddSecret, AZMGGrantAppRoles, AZMGGrantRole, SyncedToADUser, AZRoleApprover} } func NodeKinds() []graph.Kind { return []graph.Kind{Entity, VMScaleSet, App, Role, Device, FunctionApp, Group, KeyVault, ManagementGroup, ResourceGroup, ServicePrincipal, Subscription, Tenant, User, VM, ManagedCluster, ContainerRegistry, WebApp, LogicApp, AutomationAccount} diff --git a/packages/go/metrics/registry.go b/packages/go/metrics/registry.go new file mode 100644 index 00000000000..437aacd8686 --- /dev/null +++ b/packages/go/metrics/registry.go @@ -0,0 +1,165 @@ +// 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 metrics + +import ( + "strings" + "sync" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +type registry struct { + lock *sync.Mutex + prometheusRegistry *prometheus.Registry + counters map[string]prometheus.Counter + counterVecs map[string]*prometheus.CounterVec + gauges map[string]prometheus.Gauge +} + +func metricKey(name, namespace string, labels map[string]string) string { + builder := strings.Builder{} + + builder.WriteString(namespace) + builder.WriteString(name) + + for key, value := range labels { + builder.WriteString(key) + builder.WriteString(value) + } + + return builder.String() +} + +func (s *registry) Counter(name, namespace string, constLabels map[string]string) prometheus.Counter { + s.lock.Lock() + defer s.lock.Unlock() + + key := metricKey(name, namespace, constLabels) + + if counter, hasCounter := s.counters[key]; hasCounter { + return counter + } else { + newCounter := promauto.With(s.prometheusRegistry).NewCounter(prometheus.CounterOpts{ + Name: name, + Namespace: namespace, + ConstLabels: constLabels, + }) + + s.counters[key] = newCounter + newCounter.Add(0) + + return newCounter + } +} + +func (s *registry) CounterVec(name, namespace string, constLabels map[string]string, variableLabelNames []string) *prometheus.CounterVec { + s.lock.Lock() + defer s.lock.Unlock() + + key := metricKey(name, namespace, constLabels) + + if counterVec, hasCounter := s.counterVecs[key]; hasCounter { + return counterVec + } else { + newCounterVec := promauto.With(s.prometheusRegistry).NewCounterVec(prometheus.CounterOpts{ + Name: name, + Namespace: namespace, + ConstLabels: constLabels, + }, variableLabelNames) + + s.counterVecs[key] = newCounterVec + return newCounterVec + } +} + +func (s *registry) Gauge(name, namespace string, constLabels map[string]string) prometheus.Gauge { + s.lock.Lock() + defer s.lock.Unlock() + + key := metricKey(name, namespace, constLabels) + + if gauge, hasGauge := s.gauges[key]; hasGauge { + return gauge + } else { + newGauge := promauto.With(s.prometheusRegistry).NewGauge(prometheus.GaugeOpts{ + Name: name, + Namespace: namespace, + ConstLabels: constLabels, + }) + + s.gauges[key] = newGauge + newGauge.Set(0) + + return newGauge + } +} + +var ( + globalRegistry *registry +) + +func init() { + prometheusRegistry := prometheus.NewRegistry() + + // Default collectors for Golang and process stats. This will panic on failure to register. + prometheusRegistry.MustRegister( + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + collectors.NewGoCollector(), + ) + + globalRegistry = ®istry{ + lock: &sync.Mutex{}, + prometheusRegistry: prometheusRegistry, + counters: map[string]prometheus.Counter{}, + counterVecs: map[string]*prometheus.CounterVec{}, + gauges: map[string]prometheus.Gauge{}, + } +} + +func Counter(name, namespace string, labels map[string]string) prometheus.Counter { + return globalRegistry.Counter(name, namespace, labels) +} + +func CounterVec(name, namespace string, labels map[string]string, variableLabelNames []string) *prometheus.CounterVec { + return globalRegistry.CounterVec(name, namespace, labels, variableLabelNames) +} + +func Gauge(name, namespace string, labels map[string]string) prometheus.Gauge { + return globalRegistry.Gauge(name, namespace, labels) +} + +func Registerer() *prometheus.Registry { + return globalRegistry.prometheusRegistry +} + +func NewCounter(opts prometheus.CounterOpts) prometheus.Counter { + return promauto.With(Registerer()).NewCounter(opts) +} + +func NewGauge(opts prometheus.GaugeOpts) prometheus.Gauge { + return promauto.With(Registerer()).NewGauge(opts) +} + +func Register(collector prometheus.Collector) error { + return Registerer().Register(collector) +} + +func Unregister(collector prometheus.Collector) { + Registerer().Unregister(collector) +} diff --git a/packages/go/trace/trace.go b/packages/go/trace/trace.go new file mode 100644 index 00000000000..5a67382883e --- /dev/null +++ b/packages/go/trace/trace.go @@ -0,0 +1,133 @@ +// 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 trace + +import ( + "context" + "log/slog" + "sync/atomic" + "time" + + "github.com/specterops/bloodhound/packages/go/bhlog/attr" + "github.com/specterops/bloodhound/packages/go/metrics" +) + +type measureCtxKey struct{} + +var ( + nextContextID = &atomic.Uint64{} +) + +func combineArgs(args ...any) []any { + var all []any + + for _, arg := range args { + switch typedArg := arg.(type) { + case []any: + all = append(all, typedArg...) + case any: + all = append(all, typedArg) + } + } + + return all +} + +type Trace struct { + ID uint64 + Started time.Time + Level slog.Level + Namespace string + Component string +} + +func newContext(level slog.Level, namespace, component string) *Trace { + return &Trace{ + ID: nextContextID.Add(1), + Started: time.Now(), + Level: level, + Namespace: namespace, + Component: component, + } +} + +func withContext(ctx context.Context, newMeasureCtx *Trace) context.Context { + return context.WithValue(ctx, measureCtxKey{}, newMeasureCtx) +} + +func fromContext(ctx context.Context) (*Trace, bool) { + if measureCtx := ctx.Value(measureCtxKey{}); measureCtx != nil { + typedMeasureCtx, typeOK := measureCtx.(*Trace) + return typedMeasureCtx, typeOK + } + + return nil, false +} + +func Context(ctx context.Context, level slog.Level, namespace, component string) context.Context { + return withContext(ctx, newContext(level, namespace, component)) +} + +func Function(ctx context.Context, function string, startArgs ...any) func(args ...any) { + var ( + level = slog.LevelInfo + then = time.Now() + traceCtx, hasTraceCtx = fromContext(ctx) + commonArgs []any + ) + + commonArgs = combineArgs([]any{ + attr.Scope("process"), + attr.Function(function), + }, startArgs) + + if hasTraceCtx { + level = traceCtx.Level + + commonArgs = combineArgs(commonArgs, []any{ + attr.Namespace(traceCtx.Namespace), + attr.Measurement(traceCtx.ID), + }) + } + + slog.Log(ctx, level, "Function Trace", combineArgs( + commonArgs, + attr.Enter(), + startArgs, + )...) + + return func(exitArgs ...any) { + elapsed := time.Since(then) + + if hasTraceCtx { + metrics.Counter("function_trace", traceCtx.Namespace, map[string]string{ + "fn": function, + }).Add(elapsed.Seconds()) + } + + slog.Log(ctx, level, "Function Trace", combineArgs( + commonArgs, + attr.Exit(), + startArgs, + attr.Elapsed(elapsed), + exitArgs, + )...) + } +} + +func Method(ctx context.Context, receiver, function string, startArgs ...any) func(args ...any) { + return Function(ctx, receiver+"."+function, startArgs...) +} diff --git a/packages/javascript/bh-shared-ui/src/components/index.ts b/packages/javascript/bh-shared-ui/src/components/index.ts index c5d39e9c5d2..d82c283a010 100644 --- a/packages/javascript/bh-shared-ui/src/components/index.ts +++ b/packages/javascript/bh-shared-ui/src/components/index.ts @@ -33,9 +33,9 @@ export { default as CollectorCard } from './CollectorCard'; export * from './CollectorCardList'; export { default as CollectorCardList } from './CollectorCardList'; export * from './ColumnHeaders'; -export * from './ConditionalTooltip'; export * from './CommunityIcon'; export { default as CommunityIcon } from './CommunityIcon'; +export * from './ConditionalTooltip'; export * from './ConfirmationDialog'; export { default as ConfirmationDialog } from './ConfirmationDialog'; export * from './CreateMenu';