diff --git a/packages/go/analysis/ad/ad.go b/packages/go/analysis/ad/ad.go index 8c73557bf..f3c59f7df 100644 --- a/packages/go/analysis/ad/ad.go +++ b/packages/go/analysis/ad/ad.go @@ -122,7 +122,8 @@ func FetchWellKnownTierZeroEntities(ctx context.Context, db graph.Database, doma func FixWellKnownNodeTypes(ctx context.Context, db graph.Database) error { defer measure.ContextMeasure(ctx, slog.LevelInfo, "Fix well known node types")() - groupSuffixes := []string{EnterpriseKeyAdminsGroupSIDSuffix, + groupSuffixes := []string{ + EnterpriseKeyAdminsGroupSIDSuffix, KeyAdminsGroupSIDSuffix, EnterpriseDomainControllersGroupSIDSuffix, DomainAdminsGroupSIDSuffix, @@ -190,7 +191,6 @@ func RunDomainAssociations(ctx context.Context, db graph.Database) error { // TODO: Reimplement unassociated node pruning if we decide that FOSS needs unassociated node pruning return nil }) - } func grabDomainInformation(tx graph.Transaction) (map[string]string, error) { @@ -273,15 +273,20 @@ func LinkWellKnownGroups(ctx context.Context, db graph.Database) error { } } -func getOrCreateWellKnownGroup(tx graph.Transaction, wellKnownSid string, domainSid, domainName, nodeName string) (*graph.Node, error) { +func getOrCreateWellKnownGroup( + tx graph.Transaction, + wellKnownSid, domainSid, domainName, nodeName string, +) ( + *graph.Node, + error, +) { + // Only filter by ObjectID, not by kind if wellKnownNode, err := tx.Nodes().Filterf(func() graph.Criteria { - return query.And( - query.Equals(query.NodeProperty(common.ObjectID.String()), wellKnownSid), - query.Kind(query.Node(), ad.Group), - ) + return query.Equals(query.NodeProperty(common.ObjectID.String()), wellKnownSid) }).First(); err != nil && !graph.IsErrNotFound(err) { return nil, err } else if graph.IsErrNotFound(err) { + // Only create a new node if no node with this ObjectID exists properties := graph.AsProperties(graph.PropertyMap{ common.Name: nodeName, common.ObjectID: wellKnownSid, @@ -291,6 +296,15 @@ func getOrCreateWellKnownGroup(tx graph.Transaction, wellKnownSid string, domain }) return tx.CreateNode(properties, ad.Entity, ad.Group) } else { + // If a node with this ObjectID exists (regardless of its kind), return it + // Optionally, we could add the ad.Group kind if it's missing + if !wellKnownNode.Kinds.ContainsOneOf(ad.Group) { + // Add the ad.Group kind if it's missing + wellKnownNode.AddKinds(ad.Group) + if err := tx.UpdateNode(wellKnownNode); err != nil { + return nil, fmt.Errorf("failed to update node with Group kind: %w", err) + } + } return wellKnownNode, nil } } @@ -342,7 +356,6 @@ func CalculateCrossProductNodeSets(tx graph.Transaction, groupExpansions impact. // Get the IDs of the Auth. Users and Everyone groups specialGroups, err := FetchAuthUsersAndEveryoneGroups(tx) - if err != nil { slog.Error(fmt.Sprintf("Could not fetch groups: %s", err.Error())) } diff --git a/packages/go/analysis/ad/ad_test.go b/packages/go/analysis/ad/ad_test.go new file mode 100644 index 000000000..b010fd068 --- /dev/null +++ b/packages/go/analysis/ad/ad_test.go @@ -0,0 +1,230 @@ +package ad_test + +import ( + "context" + "testing" + + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/stretchr/testify/require" + + adAnalysis "github.com/specterops/bloodhound/analysis/ad" + + "github.com/specterops/bloodhound/graphschema/ad" + "github.com/specterops/bloodhound/graphschema/common" +) + +func TestLinkWellKnownGroups(t *testing.T) { + // Skip if running short tests + if testing.Short() { + t.Skip("Skipping test in short mode") + } + + // Define database drivers to test + dbDrivers := []struct { + name string + driver string + initFn func(t *testing.T, ctx context.Context) (graph.Database, func()) + }{ + { + name: "Neo4j", + driver: "neo4j", + initFn: initNeo4jDatabase, + }, + { + name: "PostgreSQL", + driver: "pg", + initFn: initPostgresDatabase, + }, + } + + testCases := []linkWellKnownGroupsTestCase{ + { + name: "Node Not Found Create New Node", + expectedNode: graph.Node{ + Kinds: graph.Kinds{ + ad.Entity, ad.Group, + }, + Properties: graph.AsProperties(graph.PropertyMap{ + common.Name: "some name", + common.ObjectID: "wellknowsid", + ad.DomainSID: "some domain", + ad.DomainFQDN: "fullyqualifieddsomain", + }), + }, + setupFunc: func( + t *testing.T, + ctx context.Context, + graphDB graph.Database, + expectedNode *graph.Node, + ) *graph.Node { + // NOTE: in order to trigger getOrCreateWellKnownGroup creating a node if the given wellKnownSid does + // not return a node + var createdNode *graph.Node + var err error + err = graphDB.WriteTransaction(ctx, func(tx graph.Transaction) error { + domainProperties := graph.AsProperties(graph.PropertyMap{ + common.Name: expectedNode.Properties.Get(common.Name.String()), + common.Collected: true, + }) + createdNode, err = tx.CreateNode(domainProperties, ad.Domain) + if err != nil { + return err + } + return tx.Commit() + }) + require.NoError(t, err) + require.NotNil(t, createdNode) + return createdNode + }, + }, + } + + // Run tests for each database driver + for _, dbDriver := range dbDrivers { + t.Run(dbDriver.name, func(t *testing.T) { + ctx := context.Background() + + // Initialize the database + graphDB, cleanup := dbDriver.initFn(t, ctx) + defer cleanup() + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Clean the database before each test + cleanDatabase(t, ctx, graphDB) + + // Set up the test case with a random domain SID and name for each test + // domainSid, domainName := generateDomainInfo() + createdNode := tc.setupFunc( + t, + ctx, + graphDB, + generateCollectedDomain(t), + ) + require.NotNil(t, createdNode) + + // Verify that the domain node was created successfully + // var domainExists bool + // err := graphDB.ReadTransaction(ctx, func(tx graph.Transaction) error { + // domain, err := tx.Nodes().Filterf(func() graph.Criteria { + // return query.And( + // query.Kind(query.Node(), ad.Domain), + // query.Equals(query.NodeProperty(common.ObjectID.String()), domainSid), + // ) + // }).First() + // if err != nil { + // return err + // } + // + // domainExists = (domain != nil) + // return nil + // }) + // require.NoError(t, err) + // require.True(t, domainExists, "Domain node was not created successfully") + + // Make sure the domain node has the required properties for LinkWellKnownGroups + // err = :graphDB.WriteTransaction(ctx, func(tx graph.Transaction) error { + // domain, err := tx.Nodes().Filterf(func() graph.Criteria { + // return query.And( + // query.Kind(query.Node(), ad.Domain), + // query.Equals(query.NodeProperty(common.ObjectID.String()), domainSid), + // ) + // }).First() + // if err != nil { + // return err + // } + // + // // Ensure domain has DomainSID property + // if _, err := domain.Properties.Get(ad.DomainSID.String()).String(); err != nil { + // domain.Properties.Set(ad.DomainSID.String(), domainSid) + // if err := tx.UpdateNode(domain); err != nil { + // return err + // } + // } + // return tx.Commit() + // }) + // require.NoError(t, err) + + // Verify that the domain node has the DomainSID property set correctly + // err = graphDB.ReadTransaction(ctx, func(tx graph.Transaction) error { + // domain, err := tx.Nodes().Filterf(func() graph.Criteria { + // return query.And( + // query.Kind(query.Node(), ad.Domain), + // query.Equals(query.NodeProperty(common.ObjectID.String()), domainSid), + // ) + // }).First() + // if err != nil { + // return err + // } + // + // domainSIDValue, err := domain.Properties.Get(ad.DomainSID.String()).String() + // if err != nil { + // return err + // } + // + // require.Equal(t, domainSid, domainSIDValue, "Domain SID property not set correctly") + // return nil + // }) + // require.NoError(t, err) + + // Run LinkWellKnownGroups + err := adAnalysis.LinkWellKnownGroups(ctx, graphDB) + require.NoError(t, err) + + // Verify the results in a read transaction + err = graphDB.ReadTransaction(ctx, func(tx graph.Transaction) error { + // for sidSuffix, shouldExist := range tc.expectedGroupExists { + // var wellKnownSid string + // if sidSuffix == "-S-1-5-11" || sidSuffix == "-S-1-1-0" { + // // For Authenticated Users and Everyone, the SID is prefixed with the domain name + // wellKnownSid = domainName + sidSuffix + // } else { + // // For other groups, the SID is prefixed with the domain SID + // wellKnownSid = domainSid + sidSuffix + // } + // + // node, err := tx.Nodes().Filterf(func() graph.Criteria { + // return query.Equals( + // query.NodeProperty(common.ObjectID.String()), + // wellKnownSid, + // ) + // }).First() + // + // if shouldExist { + // if err != nil { + // return fmt.Errorf( + // "node with SID %s should exist: %w", + // wellKnownSid, + // err, + // ) + // } + // if node == nil { + // return fmt.Errorf( + // "node with SID %s should not be nil", + // wellKnownSid, + // ) + // } + // + // // Verify the kinds + // // Check that the node has the Group kind + // if !node.Kinds.ContainsOneOf(ad.Group) { + // return fmt.Errorf( + // "node with SID %s should have kind %s", + // wellKnownSid, + // ad.Group, + // ) + // } + // } else { + // if err == nil || !graph.IsErrNotFound(err) { + // return fmt.Errorf("node with SID %s should not exist", wellKnownSid) + // } + // } + // } + return nil + }) + require.NoError(t, err) + }) + } + }) + } +} diff --git a/packages/go/analysis/ad/ad_testing_tools_test.go b/packages/go/analysis/ad/ad_testing_tools_test.go new file mode 100644 index 000000000..44252e16e --- /dev/null +++ b/packages/go/analysis/ad/ad_testing_tools_test.go @@ -0,0 +1,39 @@ +package ad_test + +import ( + "context" + "testing" + + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/specterops/bloodhound/graphschema/ad" + "github.com/specterops/bloodhound/graphschema/common" + + "github.com/rs/xid" + + faker "github.com/go-faker/faker/v4" +) + +func generateCollectedDomain() *graph.Node { + return &graph.Node{ + Kinds: graph.Kinds{ + ad.Domain, + }, + Properties: graph.AsProperties(graph.PropertyMap{ + common.Collected: true, + common.Name: faker.DomainName(), + ad.DomainSID: xid.New().String(), + ad.DomainFQDN: faker.DomainName(), + }), + } +} + +type linkWellKnownGroupsTestCase struct { + name string + expectedNode graph.Node + setupFunc func( + t *testing.T, + ctx context.Context, + graphDB graph.Database, + expectedNode *graph.Node, + ) *graph.Node +} diff --git a/packages/go/analysis/ad/db_test.go b/packages/go/analysis/ad/db_test.go new file mode 100644 index 000000000..4b124380b --- /dev/null +++ b/packages/go/analysis/ad/db_test.go @@ -0,0 +1,117 @@ +package ad_test + +import ( + "context" + "os" + "testing" + + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/specterops/bloodhound/src/bootstrap" + "github.com/specterops/bloodhound/src/config" + "github.com/specterops/bloodhound/src/database" + "github.com/specterops/bloodhound/src/services" + "github.com/stretchr/testify/require" + + "github.com/specterops/bloodhound/graphschema/common" +) + +// Get database configuration from environment variables or use defaults +func getDatabaseConfig(t *testing.T, driver string) config.Configuration { + neo4jURI := os.Getenv("BLOODHOUND_TESTING_NEO4J_URI") + if neo4jURI == "" { + neo4jURI = "neo4j://neo4j:bloodhoundcommunityedition@localhost:7687/" // Default Neo4j URI + } + + postgresURI := os.Getenv("BLOODHOUND_TESTING_POSTGRES_URI") + if postgresURI == "" { + postgresURI = "postgres://bloodhound:bloodhoundcommunityedition@localhost:5432/bloodhound?sslmode=disable" // Default PostgreSQL URI + } + + cfg := config.Configuration{ + GraphDriver: driver, + Neo4J: config.DatabaseConfiguration{ + Connection: neo4jURI, + }, + Database: config.DatabaseConfiguration{ + Connection: postgresURI, + }, + } + + return cfg +} + +// Initialize database using the Initializer pattern +func initDatabase(t *testing.T, ctx context.Context, driver string) (graph.Database, func()) { + t.Helper() + // Create configuration with database settings + cfg := getDatabaseConfig(t, driver) + require.NotEmpty(t, cfg) + + // Create an initializer with the database connector + initializer := bootstrap.Initializer[*database.BloodhoundDB, *graph.DatabaseSwitch]{ + Configuration: cfg, + DBConnector: services.ConnectDatabases, + } + require.NotNil(t, initializer) + + // Connect to the database using the initializer's DBConnector + connections, err := initializer.DBConnector(ctx, cfg) + require.NotNil(t, connections) + require.NoError(t, err, "Failed to connect to database") + + // Initialize the schema + schema := graph.Schema{ + DefaultGraph: graph.Graph{ + Name: "bloodhound", + NodeConstraints: []graph.Constraint{ + { + Name: "object_id_constraint", + Field: common.ObjectID.String(), + Type: graph.BTreeIndex, + }, + }, + }, + } + + // Assert schema on the graph database + err = connections.Graph.AssertSchema(ctx, schema) + require.NoError(t, err, "Failed to assert schema") + + // Return the graph database and a cleanup function + cleanup := func() { + t.Logf("closing database connections") + t.Logf("closing GraphDB") + if err := connections.Graph.Close(ctx); err != nil { + t.Logf("Error closing graph database: %v", err) + } + t.Logf("closing RDMS") + connections.RDMS.Close(ctx) + } + + return connections.Graph, cleanup +} + +// Initialize Neo4j database +func initNeo4jDatabase(t *testing.T, ctx context.Context) (graph.Database, func()) { + return initDatabase(t, ctx, "neo4j") +} + +// Initialize PostgreSQL database +func initPostgresDatabase(t *testing.T, ctx context.Context) (graph.Database, func()) { + return initDatabase(t, ctx, "pg") +} + +// Helper function to clean the database +func cleanDatabase(t *testing.T, ctx context.Context, graphDB graph.Database) { + t.Helper() + err := graphDB.WriteTransaction(ctx, func(tx graph.Transaction) error { + // Delete all nodes and relationships + result := tx.Raw("MATCH (n) DETACH DELETE n", nil) + defer result.Close() + if err := result.Error(); err != nil { + return err + } + return tx.Commit() + }) + require.NoError(t, err, "Failed to clean the database") +} diff --git a/packages/go/analysis/go.mod b/packages/go/analysis/go.mod index 9b4779d11..71a599b65 100644 --- a/packages/go/analysis/go.mod +++ b/packages/go/analysis/go.mod @@ -21,6 +21,7 @@ go 1.23 require ( github.com/RoaringBitmap/roaring v1.9.4 github.com/bloodhoundad/azurehound/v2 v2.0.1 + github.com/go-faker/faker/v4 v4.6.0 github.com/stretchr/testify v1.9.0 go.uber.org/mock v0.2.0 ) @@ -32,6 +33,8 @@ require ( github.com/mschoch/smat v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/rs/xid v1.6.0 // indirect + golang.org/x/text v0.21.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/packages/go/analysis/go.sum b/packages/go/analysis/go.sum index 1432357b6..8637d8e4f 100644 --- a/packages/go/analysis/go.sum +++ b/packages/go/analysis/go.sum @@ -1,13 +1,43 @@ github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ= +github.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90= github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA= +github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bloodhoundad/azurehound/v2 v2.0.1 h1:eCDfBrBGvY9FDyAfCFvWVRpMJE9tLkixnO8X/jRiaWE= +github.com/bloodhoundad/azurehound/v2 v2.0.1/go.mod h1:vqka/ebTCUHBE5llbB3WZoClGxUkUPLkVyv8NziPnbk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-faker/faker/v4 v4.6.0 h1:6aOPzNptRiDwD14HuAnEtlTa+D1IfFuEHO8+vEFwjTs= +github.com/go-faker/faker/v4 v4.6.0/go.mod h1:ZmrHuVtTTm2Em9e0Du6CJ9CADaLEzGXW62z1YqFH0m0= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.uber.org/mock v0.2.0 h1:TaP3xedm7JaAgScZO7tlvlKrqT0p7I6OsdGB5YNSMDU= +go.uber.org/mock v0.2.0/go.mod h1:J0y0rp9L3xiff1+ZBfKxlC1fz2+aO16tw0tsDOixfuM= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=