diff --git a/.github/workflows/jira-issue-transfer.yml b/.github/workflows/jira-issue-transfer.yml index 7bc635b4f9..33c59c96cd 100644 --- a/.github/workflows/jira-issue-transfer.yml +++ b/.github/workflows/jira-issue-transfer.yml @@ -17,8 +17,8 @@ name: Jira Issue Transfer on: issues: - types: - - opened + types: + - labeled jobs: build: runs-on: self-hosted @@ -29,13 +29,25 @@ jobs: JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} - - name: Jira Create - uses: atlassian/gajira-create@v3 - with: - project: BP - issuetype: 'Bug Report' - summary: ${{ github.event.issue.title }} - description: "Github Issue Link: ${{ github.event.issue.url}} \r\n ${{ github.event.issue.body }}" - fields: '{"labels":["GitHubReport"]}' - env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: github.event.label.name == 'ticketed' && !contains(github.event.issue.labels.*.name, 'ticketed') + if: contains(github.event.issue.labels.*.name, 'bug') + - name: Jira Create + uses: atlassian/gajira-create@v3 + with: + project: BP + issuetype: 'Bug Report' + summary: ${{ github.event.issue.title }} + description: "Github Issue Link: ${{ github.event.issue.html_url}} \r\n ${{ github.event.issue.body }}" + fields: '{"labels":["GitHubReport"]}' + + if: contains(github.event.issue.labels.*.name, 'enhancement') + - name: Jira Create + uses: atlassian/gajira-create@v3 + with: + project: BP + issuetype: 'Product Feature' + summary: ${{ github.event.issue.title }} + description: "Github Issue Link: ${{ github.event.issue.html_url}} \r\n ${{ github.event.issue.body }}" + fields: '{"labels":["GitHubReport"]}' + \ No newline at end of file diff --git a/.github/workflows/pr-jira-issue-transfer.yml b/.github/workflows/pr-jira-issue-transfer.yml new file mode 100644 index 0000000000..12598a0067 --- /dev/null +++ b/.github/workflows/pr-jira-issue-transfer.yml @@ -0,0 +1,55 @@ +# 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 +name: PR-Ticket-OoOrg + +on: + pull_request: + types: [opened, synchronize] + +jobs: + build: + runs-on: self-hosted + steps: + - name: Check if PR is from within the organization + id: check_org + run: + PR_URL=$(jq -r .pull_request.url "$GITHUB_EVENT_PATH") + PR_NUMBER=$(jq -r .pull_request.number "$GITHUB_EVENT_PATH") + TEAM_SLUG="bloodhound-engineering" # Replace with your team's slug + GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" + PR_ORG=$(jq -r .pull_request.head.repo.organization.login "$GITHUB_EVENT_PATH") + PR_USER=$(jq -r .pull_request.user.login "$GITHUB_EVENT_PATH") + TEAM_MEMBERSHIP=$(curl -s -X GET -H "Authorization: token $GITHUB_TOKEN" \ + "https://api.github.com/orgs/$PR_ORG/teams/$TEAM_SLUG/memberships/$PR_USER") + + env: + JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} + JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + if [ -z "$TEAM_MEMBERSHIP" ]; then + - name: Login + uses: atlassian/gajira-login@v3 + - name: Jira Create + uses: atlassian/gajira-create@v3 + with: + project: BP + issuetype: 'Product Feature' + summary: ${{ github.event.pulls.title }} + description: "Github Pull Request Link: ${{ github.event.pulls.html_url}} \r\n ${{ github.event.pulls.body }}" + fields: '{"labels":["GitHubReport"]}' + diff --git a/cmd/api/src/analysis/ad/adcs_integration_test.go b/cmd/api/src/analysis/ad/adcs_integration_test.go index 37e0b8bc22..26eda79dd2 100644 --- a/cmd/api/src/analysis/ad/adcs_integration_test.go +++ b/cmd/api/src/analysis/ad/adcs_integration_test.go @@ -21,6 +21,7 @@ package ad_test import ( "context" + "github.com/specterops/bloodhound/analysis" ad2 "github.com/specterops/bloodhound/analysis/ad" "github.com/specterops/bloodhound/analysis/impact" @@ -2928,42 +2929,42 @@ func TestExtendedByPolicyBinding(t *testing.T) { operation.Done() db.ReadTransaction(context.Background(), func(tx graph.Transaction) error { - if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.IssuancePolicy0.ID, harness.ExtendedByPolicyHarness.CertTemplate1.ID, ad.ExtendedByPolicy); err != nil { + if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.CertTemplate1.ID, harness.ExtendedByPolicyHarness.IssuancePolicy0.ID, ad.ExtendedByPolicy); err != nil { t.Fatalf("error fetching ExtendedByPolicy edge (1) in integration test; %v", err) } else { require.NotNil(t, edge) } - if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.IssuancePolicy1.ID, harness.ExtendedByPolicyHarness.CertTemplate1.ID, ad.ExtendedByPolicy); err != nil { + if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.CertTemplate1.ID, harness.ExtendedByPolicyHarness.IssuancePolicy1.ID, ad.ExtendedByPolicy); err != nil { t.Fatalf("error fetching ExtendedByPolicy edge (2) in integration test; %v", err) } else { require.NotNil(t, edge) } - if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.IssuancePolicy0.ID, harness.ExtendedByPolicyHarness.CertTemplate2.ID, ad.ExtendedByPolicy); err != nil { + if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.CertTemplate2.ID, harness.ExtendedByPolicyHarness.IssuancePolicy0.ID, ad.ExtendedByPolicy); err != nil { t.Fatalf("error fetching ExtendedByPolicy edge (3) in integration test; %v", err) } else { require.NotNil(t, edge) } - if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.IssuancePolicy2.ID, harness.ExtendedByPolicyHarness.CertTemplate2.ID, ad.ExtendedByPolicy); err != nil { + if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.CertTemplate2.ID, harness.ExtendedByPolicyHarness.IssuancePolicy2.ID, ad.ExtendedByPolicy); err != nil { t.Fatalf("error fetching ExtendedByPolicy edge (4) in integration test; %v", err) } else { require.NotNil(t, edge) } - if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.IssuancePolicy3.ID, harness.ExtendedByPolicyHarness.CertTemplate3.ID, ad.ExtendedByPolicy); err != nil { + if edge, err := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.CertTemplate3.ID, harness.ExtendedByPolicyHarness.IssuancePolicy3.ID, ad.ExtendedByPolicy); err != nil { t.Fatalf("error fetching ExtendedByPolicy edge (5) in integration test; %v", err) } else { require.NotNil(t, edge) } // CertificatePolicy doesn't match CertTemplateOID - edge, _ := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.IssuancePolicy1.ID, harness.ExtendedByPolicyHarness.CertTemplate2.ID, ad.ExtendedByPolicy) + edge, _ := analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.CertTemplate2.ID, harness.ExtendedByPolicyHarness.IssuancePolicy1.ID, ad.ExtendedByPolicy) require.Nil(t, edge, "ExtendedByPolicy edge exists between IssuancePolicy1 and CertTemplate2 where it shouldn't") // Different domains, no edge - edge, _ = analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.IssuancePolicy4.ID, harness.ExtendedByPolicyHarness.CertTemplate4.ID, ad.ExtendedByPolicy) + edge, _ = analysis.FetchEdgeByStartAndEnd(testContext.Context(), db, harness.ExtendedByPolicyHarness.CertTemplate4.ID, harness.ExtendedByPolicyHarness.IssuancePolicy4.ID, ad.ExtendedByPolicy) require.Nil(t, edge, "ExtendedByPolicy edge bridges domains where it shouldn't") return nil diff --git a/cmd/api/src/api/middleware/middleware.go b/cmd/api/src/api/middleware/middleware.go index d899187e7d..43eca4216e 100644 --- a/cmd/api/src/api/middleware/middleware.go +++ b/cmd/api/src/api/middleware/middleware.go @@ -40,6 +40,11 @@ import ( "github.com/unrolled/secure" ) +const ( + // Default timeout for any request is thirty seconds + defaultTimeout = 30 * time.Second +) + // Wrapper is an iterator for middleware function application that wraps around a http.Handler. type Wrapper struct { middleware []mux.MiddlewareFunc diff --git a/cmd/api/src/api/signature.go b/cmd/api/src/api/signature.go index 66b60a7e06..99aabcffed 100644 --- a/cmd/api/src/api/signature.go +++ b/cmd/api/src/api/signature.go @@ -208,3 +208,16 @@ func SignRequestAtTime(hasher func() hash.Hash, id string, token string, datetim func SignRequest(tokenID, token string, request *http.Request) error { return SignRequestAtTime(sha256.New, tokenID, token, time.Now(), request) } + +type readerDelegatedCloser struct { + source io.Reader + closer io.Closer +} + +func (s readerDelegatedCloser) Read(p []byte) (n int, err error) { + return s.source.Read(p) +} + +func (s readerDelegatedCloser) Close() error { + return s.closer.Close() +} diff --git a/cmd/api/src/api/v2/cypherquery.go b/cmd/api/src/api/v2/cypherquery.go index b1450a1941..655a635fc4 100644 --- a/cmd/api/src/api/v2/cypherquery.go +++ b/cmd/api/src/api/v2/cypherquery.go @@ -69,6 +69,8 @@ func (s Resources) CypherQuery(response http.ResponseWriter, request *http.Reque } else { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, err.Error(), request), response) } + } else if !preparedQuery.HasMutation && len(graphResponse.Nodes)+len(graphResponse.Edges) == 0 { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, "resource not found", request), response) } else { api.WriteBasicResponse(request.Context(), graphResponse, http.StatusOK, response) } diff --git a/cmd/api/src/api/v2/cypherquery_integration_test.go b/cmd/api/src/api/v2/cypherquery_integration_test.go index 0bcceac21f..244c53780b 100644 --- a/cmd/api/src/api/v2/cypherquery_integration_test.go +++ b/cmd/api/src/api/v2/cypherquery_integration_test.go @@ -21,13 +21,12 @@ package v2_test import ( "bytes" - "fmt" + "testing" + "github.com/specterops/bloodhound/cypher/backend/cypher" "github.com/specterops/bloodhound/src/auth" "github.com/specterops/bloodhound/src/model" "github.com/specterops/bloodhound/src/utils/test" - "net/http" - "testing" "github.com/specterops/bloodhound/cypher/frontend" "github.com/specterops/bloodhound/graphschema/common" @@ -144,7 +143,7 @@ func Test_CypherSearch_WithCypherMutationsEnabled(t *testing.T) { assert.True(ok) var ( - query = "match (w) where w.name = 'vldmrt' remove w.name return w" + query = "match (w: Wizard) where w.name = 'vldmrt' remove w.name return w" strippedQuery = &bytes.Buffer{} ) parsedQuery, err := frontend.ParseCypher(parseCtx, query) @@ -169,7 +168,7 @@ func Test_CypherSearch_WithCypherMutationsEnabled(t *testing.T) { assert.True(ok) var ( - query = "match (w) where w.name = 'harryp' delete w" + query = "match (w: Wizard) where w.name = 'harryp' delete w" strippedQuery = &bytes.Buffer{} ) parsedQuery, err := frontend.ParseCypher(parseCtx, query) @@ -192,12 +191,12 @@ func Test_CypherSearch_WithCypherMutationsEnabled(t *testing.T) { assert.True(found) }), - lab.TestCase("adds failed mutation attempts to audit log", func(assert *require.Assertions, harness *lab.Harness) { + lab.TestCase("adds mutation attempts to audit log", func(assert *require.Assertions, harness *lab.Harness) { apiClient, ok := lab.Unpack(harness, adminApiClientFixture) assert.True(ok) var ( - query = "match (w) set w.wizard = true return w.wizard" + query = "match (w: Wizard) set w.wizard = true return w.wizard" strippedQuery = &bytes.Buffer{} ) @@ -207,12 +206,12 @@ func Test_CypherSearch_WithCypherMutationsEnabled(t *testing.T) { require.Nil(t, err) _, err = apiClient.CypherQuery(v2.CypherQueryPayload{Query: query}) - assert.ErrorContains(err, fmt.Sprintf("%d", http.StatusInternalServerError)) + require.Nil(t, err) auditLogs, err := apiClient.GetLatestAuditLogs() assert.Nil(err) - err = test.AssertAuditLogs(auditLogs.Logs, model.AuditLogActionMutateGraph, model.AuditLogStatusFailure, model.AuditData{"query": strippedQuery.String()}) + err = test.AssertAuditLogs(auditLogs.Logs, model.AuditLogActionMutateGraph, model.AuditLogStatusSuccess, model.AuditData{"query": strippedQuery.String()}) assert.Nil(err) }), ) diff --git a/cmd/api/src/api/v2/file_uploads.go b/cmd/api/src/api/v2/file_uploads.go index 3d19522fa4..dd43fc4349 100644 --- a/cmd/api/src/api/v2/file_uploads.go +++ b/cmd/api/src/api/v2/file_uploads.go @@ -131,7 +131,7 @@ func (s Resources) ProcessFileUpload(response http.ResponseWriter, request *http } if !IsValidContentTypeForUpload(request.Header) { - api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "Content type must be application/json or application/zip", request), response) + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("Content type must be application/json or application/zip"), request), response) } else if fileUploadJobID, err := strconv.Atoi(fileUploadJobIdString); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, request), response) } else if fileUploadJob, err := fileupload.GetFileUploadJobByID(request.Context(), s.DB, int64(fileUploadJobID)); err != nil { diff --git a/cmd/api/src/daemons/datapipe/decoders.go b/cmd/api/src/daemons/datapipe/decoders.go index 5c8a010ff7..3ded120d8d 100644 --- a/cmd/api/src/daemons/datapipe/decoders.go +++ b/cmd/api/src/daemons/datapipe/decoders.go @@ -19,7 +19,6 @@ package datapipe import ( "errors" "github.com/specterops/bloodhound/dawgs/graph" - "github.com/specterops/bloodhound/dawgs/util" "github.com/specterops/bloodhound/ein" "github.com/specterops/bloodhound/log" "io" @@ -41,7 +40,6 @@ func decodeBasicData[T any](batch graph.Batch, reader io.ReadSeeker, conversionF var ( count = 0 convertedData ConvertedData - errs = util.NewErrorCollector() ) for decoder.More() { @@ -58,22 +56,17 @@ func decodeBasicData[T any](batch graph.Batch, reader io.ReadSeeker, conversionF } if count == IngestCountThreshold { - if err = IngestBasicData(batch, convertedData); err != nil { - errs.Add(err) - } + IngestBasicData(batch, convertedData) convertedData.Clear() count = 0 - } } if count > 0 { - if err = IngestBasicData(batch, convertedData); err != nil { - errs.Add(err) - } + IngestBasicData(batch, convertedData) } - return errs.Combined() + return nil } func decodeGroupData(batch graph.Batch, reader io.ReadSeeker) error { @@ -85,12 +78,11 @@ func decodeGroupData(batch graph.Batch, reader io.ReadSeeker) error { var ( convertedData = ConvertedGroupData{} count = 0 - errs = util.NewErrorCollector() ) for decoder.More() { var group ein.Group - if err = decoder.Decode(&group); err != nil { + if err := decoder.Decode(&group); err != nil { log.Errorf("Error decoding group object: %v", err) if errors.Is(err, io.EOF) { break @@ -99,10 +91,7 @@ func decodeGroupData(batch graph.Batch, reader io.ReadSeeker) error { count++ convertGroupData(group, &convertedData) if count == IngestCountThreshold { - if err = IngestGroupData(batch, convertedData); err != nil { - errs.Add(err) - } - + IngestGroupData(batch, convertedData) convertedData.Clear() count = 0 } @@ -110,12 +99,10 @@ func decodeGroupData(batch graph.Batch, reader io.ReadSeeker) error { } if count > 0 { - if err = IngestGroupData(batch, convertedData); err != nil { - errs.Add(err) - } + IngestGroupData(batch, convertedData) } - return errs.Combined() + return nil } func decodeSessionData(batch graph.Batch, reader io.ReadSeeker) error { @@ -127,11 +114,10 @@ func decodeSessionData(batch graph.Batch, reader io.ReadSeeker) error { var ( convertedData = ConvertedSessionData{} count = 0 - errs = util.NewErrorCollector() ) for decoder.More() { var session ein.Session - if err = decoder.Decode(&session); err != nil { + if err := decoder.Decode(&session); err != nil { log.Errorf("Error decoding session object: %v", err) if errors.Is(err, io.EOF) { break @@ -140,9 +126,7 @@ func decodeSessionData(batch graph.Batch, reader io.ReadSeeker) error { count++ convertSessionData(session, &convertedData) if count == IngestCountThreshold { - if err = IngestSessions(batch, convertedData.SessionProps); err != nil { - errs.Add(err) - } + IngestSessions(batch, convertedData.SessionProps) convertedData.Clear() count = 0 } @@ -150,12 +134,10 @@ func decodeSessionData(batch graph.Batch, reader io.ReadSeeker) error { } if count > 0 { - if err = IngestSessions(batch, convertedData.SessionProps); err != nil { - errs.Add(err) - } + IngestSessions(batch, convertedData.SessionProps) } - return errs.Combined() + return nil } func decodeAzureData(batch graph.Batch, reader io.ReadSeeker) error { @@ -167,12 +149,11 @@ func decodeAzureData(batch graph.Batch, reader io.ReadSeeker) error { var ( convertedData = ConvertedAzureData{} count = 0 - errs = util.NewErrorCollector() ) for decoder.More() { var data AzureBase - if err = decoder.Decode(&data); err != nil { + if err := decoder.Decode(&data); err != nil { log.Errorf("Error decoding azure object: %v", err) if errors.Is(err, io.EOF) { break @@ -182,9 +163,7 @@ func decodeAzureData(batch graph.Batch, reader io.ReadSeeker) error { convert(data.Data, &convertedData) count++ if count == IngestCountThreshold { - if err = IngestAzureData(batch, convertedData); err != nil { - errs.Add(err) - } + IngestAzureData(batch, convertedData) convertedData.Clear() count = 0 } @@ -192,10 +171,8 @@ func decodeAzureData(batch graph.Batch, reader io.ReadSeeker) error { } if count > 0 { - if err = IngestAzureData(batch, convertedData); err != nil { - errs.Add(err) - } + IngestAzureData(batch, convertedData) } - return errs.Combined() + return nil } diff --git a/cmd/api/src/daemons/datapipe/ingest.go b/cmd/api/src/daemons/datapipe/ingest.go index f9b6b2bb0c..9d6404e825 100644 --- a/cmd/api/src/daemons/datapipe/ingest.go +++ b/cmd/api/src/daemons/datapipe/ingest.go @@ -18,7 +18,6 @@ package datapipe import ( "fmt" - "github.com/specterops/bloodhound/dawgs/util" "github.com/specterops/bloodhound/src/model/ingest" "github.com/specterops/bloodhound/src/services/fileupload" "io" @@ -45,54 +44,21 @@ func ReadFileForIngest(batch graph.Batch, reader io.ReadSeeker, adcsEnabled bool } } -func IngestBasicData(batch graph.Batch, converted ConvertedData) error { - errs := util.NewErrorCollector() - - if err := IngestNodes(batch, ad.Entity, converted.NodeProps); err != nil { - errs.Add(err) - } - - if err := IngestRelationships(batch, ad.Entity, converted.RelProps); err != nil { - errs.Add(err) - } - - return errs.Combined() +func IngestBasicData(batch graph.Batch, converted ConvertedData) { + IngestNodes(batch, ad.Entity, converted.NodeProps) + IngestRelationships(batch, ad.Entity, converted.RelProps) } -func IngestGroupData(batch graph.Batch, converted ConvertedGroupData) error { - errs := util.NewErrorCollector() - - if err := IngestNodes(batch, ad.Entity, converted.NodeProps); err != nil { - errs.Add(err) - } - - if err := IngestRelationships(batch, ad.Entity, converted.RelProps); err != nil { - errs.Add(err) - } - - if err := IngestDNRelationships(batch, converted.DistinguishedNameProps); err != nil { - errs.Add(err) - } - - return errs.Combined() +func IngestGroupData(batch graph.Batch, converted ConvertedGroupData) { + IngestNodes(batch, ad.Entity, converted.NodeProps) + IngestRelationships(batch, ad.Entity, converted.RelProps) + IngestDNRelationships(batch, converted.DistinguishedNameProps) } -func IngestAzureData(batch graph.Batch, converted ConvertedAzureData) error { - errs := util.NewErrorCollector() - - if err := IngestNodes(batch, azure.Entity, converted.NodeProps); err != nil { - errs.Add(err) - } - - if err := IngestNodes(batch, ad.Entity, converted.OnPremNodes); err != nil { - errs.Add(err) - } - - if err := IngestRelationships(batch, azure.Entity, converted.RelProps); err != nil { - errs.Add(err) - } - - return errs.Combined() +func IngestAzureData(batch graph.Batch, converted ConvertedAzureData) { + IngestNodes(batch, azure.Entity, converted.NodeProps) + IngestNodes(batch, ad.Entity, converted.OnPremNodes) + IngestRelationships(batch, azure.Entity, converted.RelProps) } func IngestWrapper(batch graph.Batch, reader io.ReadSeeker, meta ingest.Metadata, adcsEnabled bool) error { @@ -175,19 +141,14 @@ func IngestNode(batch graph.Batch, nowUTC time.Time, identityKind graph.Kind, ne }) } -func IngestNodes(batch graph.Batch, identityKind graph.Kind, nodes []ein.IngestibleNode) error { - var ( - nowUTC = time.Now().UTC() - errs = util.NewErrorCollector() - ) +func IngestNodes(batch graph.Batch, identityKind graph.Kind, nodes []ein.IngestibleNode) { + nowUTC := time.Now().UTC() for _, next := range nodes { if err := IngestNode(batch, nowUTC, identityKind, next); err != nil { - log.Errorf("Error ingesting node ID %s: %v", next.ObjectID, err) - errs.Add(err) + log.Errorf("Error ingesting node: %v", err) } } - return errs.Combined() } func IngestRelationship(batch graph.Batch, nowUTC time.Time, nodeIDKind graph.Kind, nextRel ein.IngestibleRelationship) error { @@ -218,19 +179,14 @@ func IngestRelationship(batch graph.Batch, nowUTC time.Time, nodeIDKind graph.Ki }) } -func IngestRelationships(batch graph.Batch, nodeIDKind graph.Kind, relationships []ein.IngestibleRelationship) error { - var ( - nowUTC = time.Now().UTC() - errs = util.NewErrorCollector() - ) +func IngestRelationships(batch graph.Batch, nodeIDKind graph.Kind, relationships []ein.IngestibleRelationship) { + nowUTC := time.Now().UTC() for _, next := range relationships { if err := IngestRelationship(batch, nowUTC, nodeIDKind, next); err != nil { - log.Errorf("Error ingesting relationship from %s to %s : %v", next.Source, next.Target, err) - errs.Add(err) + log.Errorf("Error ingesting relationship from basic data : %v ", err) } } - return errs.Combined() } func ingestDNRelationship(batch graph.Batch, nowUTC time.Time, nextRel ein.IngestibleRelationship) error { @@ -261,19 +217,14 @@ func ingestDNRelationship(batch graph.Batch, nowUTC time.Time, nextRel ein.Inges }) } -func IngestDNRelationships(batch graph.Batch, relationships []ein.IngestibleRelationship) error { - var ( - nowUTC = time.Now().UTC() - errs = util.NewErrorCollector() - ) +func IngestDNRelationships(batch graph.Batch, relationships []ein.IngestibleRelationship) { + nowUTC := time.Now().UTC() for _, next := range relationships { if err := ingestDNRelationship(batch, nowUTC, next); err != nil { log.Errorf("Error ingesting relationship: %v", err) - errs.Add(err) } } - return errs.Combined() } func ingestSession(batch graph.Batch, nowUTC time.Time, nextSession ein.IngestibleSession) error { @@ -306,17 +257,12 @@ func ingestSession(batch graph.Batch, nowUTC time.Time, nextSession ein.Ingestib }) } -func IngestSessions(batch graph.Batch, sessions []ein.IngestibleSession) error { - var ( - nowUTC = time.Now().UTC() - errs = util.NewErrorCollector() - ) +func IngestSessions(batch graph.Batch, sessions []ein.IngestibleSession) { + nowUTC := time.Now().UTC() for _, next := range sessions { if err := ingestSession(batch, nowUTC, next); err != nil { log.Errorf("Error ingesting sessions: %v", err) - errs.Add(err) } } - return errs.Combined() } diff --git a/cmd/api/src/docs/json/paths/v2/graphs.json b/cmd/api/src/docs/json/paths/v2/graphs.json index 3a6111bac8..f26be31664 100644 --- a/cmd/api/src/docs/json/paths/v2/graphs.json +++ b/cmd/api/src/docs/json/paths/v2/graphs.json @@ -78,7 +78,7 @@ }, "responses": { "200": { - "description": "Returns graph data related to the cypher query sent in the response body that contains a collection of nodes and edges", + "description": "Returns graph data related to the cypher query sent in the response body that contains a collection of nodes and edges. Supports mutation if EnableCypherMutations config flag is true.", "content": { "application/json": { "schema": { @@ -87,6 +87,12 @@ } } }, + "404": { + "description": "Not Found, returned when no nodes or edges are returned during read (non-mutation) queries.", + "schema": { + "$ref": "#/definitions/api.ErrorWrapper" + } + }, "Error": { "$ref": "#/components/responses/defaultError" } diff --git a/cmd/api/src/queries/graph_test.go b/cmd/api/src/queries/graph_test.go index 9fbde11c43..e0d8fed989 100644 --- a/cmd/api/src/queries/graph_test.go +++ b/cmd/api/src/queries/graph_test.go @@ -48,8 +48,8 @@ func TestGraphQuery_PrepareCypherQuery(t *testing.T) { gq = queries.NewGraphQuery(mockGraphDB, cache.Cache{}, config.Configuration{EnableCypherMutations: true}) gqMutDisable = queries.NewGraphQuery(mockGraphDB, cache.Cache{}, config.Configuration{EnableCypherMutations: false}) - rawCypherRead = "MATCH (n) return n" - rawCypherMutation = "DETACH DELETE (n)" + rawCypherRead = "MATCH (n:Label) return n" + rawCypherMutation = "DETACH DELETE (n:Label)" rawCypherInvalid = "derp" ) @@ -121,7 +121,7 @@ func TestGraphQuery_RawCypherQuery(t *testing.T) { return nil }) - preparedQuery, err := gq.PrepareCypherQuery("match (n) return n;") + preparedQuery, err := gq.PrepareCypherQuery("match (n:Label) return n;") require.Nil(t, err) _, err = gq.RawCypherQuery(outerBHCtxInst.ConstructGoContext(), preparedQuery, false) diff --git a/cmd/ui/src/ducks/explore/saga.ts b/cmd/ui/src/ducks/explore/saga.ts index b58b16df70..9b885d72c3 100644 --- a/cmd/ui/src/ducks/explore/saga.ts +++ b/cmd/ui/src/ducks/explore/saga.ts @@ -203,7 +203,7 @@ function* runCypherSearchQuery(payload: CypherQueryRequest): SagaIterator { ); } else if (resultNodesAreEmpty && resultEdgesAreEmpty) { yield put(putGraphData({})); - yield put(addSnackbar('No results match your criteria', 'cypherSearchEmptyResponse')); + yield put(addSnackbar('Command completed successfully', 'cypherSuccessResponse')); } else { yield put(saveResponseForExport(data)); @@ -215,6 +215,9 @@ function* runCypherSearchQuery(payload: CypherQueryRequest): SagaIterator { if (error?.response?.status === 400) { yield put(addSnackbar(apiErrorMessage, 'cypherSearchBadRequest')); + } else if (error?.response?.status === 404) { + yield put(putGraphData({})); + yield put(addSnackbar('No results match your criteria', 'cypherSearchEmptyResponse')); } else { if (apiErrorMessage) { yield put(addSnackbar(`${apiErrorMessage}`, 'cypherSearch')); diff --git a/cmd/ui/src/setupTests.tsx b/cmd/ui/src/setupTests.tsx index be6542b56a..31db6df700 100644 --- a/cmd/ui/src/setupTests.tsx +++ b/cmd/ui/src/setupTests.tsx @@ -42,7 +42,7 @@ if (typeof window.URL.createObjectURL === 'undefined') { vi.mock('@neo4j-cypher/react-codemirror', async () => { return { - CypherEditor: () => 'cypher search', + CypherEditor: () => 'cypher query', }; }); diff --git a/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.test.tsx b/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.test.tsx index 8c487bfe99..3104ee991a 100644 --- a/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.test.tsx +++ b/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.test.tsx @@ -27,10 +27,10 @@ describe('CypherSearch', () => { const user = userEvent.setup(); it('should render', () => { - expect(screen.getByText(/cypher search/i)).toBeInTheDocument(); + expect(screen.getByText(/cypher query/i)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /help/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument(); }); it('should show common cypher searches when user clicks on folder button', async () => { diff --git a/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.tsx b/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.tsx index d39dd4529d..44945b37f9 100644 --- a/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.tsx +++ b/cmd/ui/src/views/Explore/ExploreSearch/CypherSearch.tsx @@ -175,7 +175,7 @@ const CypherSearch = () => { schema={schema} lineWrapping lint - placeholder='Cypher Search' + placeholder='Cypher Query' /> @@ -218,7 +218,7 @@ const CypherSearch = () => { }> - Search + Run diff --git a/cmd/ui/src/views/Explore/ExploreSearch/ExploreSearch.test.tsx b/cmd/ui/src/views/Explore/ExploreSearch/ExploreSearch.test.tsx index e11d188365..532d438179 100644 --- a/cmd/ui/src/views/Explore/ExploreSearch/ExploreSearch.test.tsx +++ b/cmd/ui/src/views/Explore/ExploreSearch/ExploreSearch.test.tsx @@ -62,10 +62,10 @@ describe('ExploreSearch rendering per tab', async () => { await user.click(cypherTab); - expect(screen.getByText(/cypher search/i)).toBeInTheDocument(); + expect(screen.getByText(/cypher query/i)).toBeInTheDocument(); expect(screen.getByRole('link', { name: /help/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /search/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /run/i })).toBeInTheDocument(); }); it('should hide/expand search widget when user clicks minus/plus button', async () => { diff --git a/dockerfiles/bloodhound.Dockerfile b/dockerfiles/bloodhound.Dockerfile index e18baed3aa..0468953983 100644 --- a/dockerfiles/bloodhound.Dockerfile +++ b/dockerfiles/bloodhound.Dockerfile @@ -17,8 +17,8 @@ ######## # Global build args ################ -ARG SHARPHOUND_VERSION=v2.3.3 -ARG AZUREHOUND_VERSION=v2.1.8 +ARG SHARPHOUND_VERSION=v2.4.1 +ARG AZUREHOUND_VERSION=v2.1.9 ######## # Builder init diff --git a/packages/cue/bh/ad/ad.cue b/packages/cue/bh/ad/ad.cue index eb5896b1e1..1320f2fc88 100644 --- a/packages/cue/bh/ad/ad.cue +++ b/packages/cue/bh/ad/ad.cue @@ -549,7 +549,7 @@ OID: types.#StringEnum & { CertificatePolicy: types.#StringEnum & { symbol: "CertificatePolicy" schema: "ad" - name: "Certificate Policy" + name: "Issuance Policy Extensions" representation: "certificatepolicy" } diff --git a/packages/go/analysis/ad/esc_shared.go b/packages/go/analysis/ad/esc_shared.go index 8474d5bc75..12cdaf0d5b 100644 --- a/packages/go/analysis/ad/esc_shared.go +++ b/packages/go/analysis/ad/esc_shared.go @@ -183,8 +183,8 @@ func PostExtendedByPolicyBinding(operation analysis.StatTrackedOperation[analysi } else if certTemplateDomain != "" && certTemplateDomain == issuancePolicyDomain { // Create ExtendedByPolicy edge if !channels.Submit(ctx, outC, analysis.CreatePostRelationshipJob{ - FromID: issuancePolicy.ID, - ToID: certTemplate.ID, + FromID: certTemplate.ID, + ToID: issuancePolicy.ID, Kind: ad.ExtendedByPolicy, }) { return fmt.Errorf("context timed out while creating ExtendedByPolicy edge") diff --git a/packages/go/cypher/analyzer/analyzer.go b/packages/go/cypher/analyzer/analyzer.go index 1d21f073b9..174c024b9c 100644 --- a/packages/go/cypher/analyzer/analyzer.go +++ b/packages/go/cypher/analyzer/analyzer.go @@ -18,7 +18,6 @@ package analyzer import ( "errors" - "fmt" "github.com/specterops/bloodhound/cypher/model" "github.com/specterops/bloodhound/dawgs/graph" ) @@ -39,7 +38,7 @@ func (s *Analyzer) walkFunc(stack *model.WalkStack, expression model.Expression) return errors.Join(errs...) } -func (s *Analyzer) Analyze(query any, extensions ...model.CollectorFunc) error { +func (s *Analyzer) analyze(query any, extensions ...model.CollectorFunc) error { return model.Walk(query, model.NewVisitor(s.walkFunc, nil), extensions...) } @@ -47,12 +46,12 @@ func Analyze(query any, registrationFunc func(analyzerInst *Analyzer), extension analyzer := &Analyzer{} registrationFunc(analyzer) - return analyzer.Analyze(query, extensions...) + return analyzer.analyze(query, extensions...) } -type TypedVisitor[T model.Expression] func(stack *model.WalkStack, node T) error +type typedVisitor[T model.Expression] func(stack *model.WalkStack, node T) error -func WithVisitor[T model.Expression](analyzer *Analyzer, visitorFunc TypedVisitor[T]) { +func WithVisitor[T model.Expression](analyzer *Analyzer, visitorFunc typedVisitor[T]) { analyzer.handlers = append(analyzer.handlers, func(walkStack *model.WalkStack, node model.Expression) error { if typedNode, typeOK := node.(T); typeOK { if err := visitorFunc(walkStack, typedNode); err != nil { @@ -64,181 +63,6 @@ func WithVisitor[T model.Expression](analyzer *Analyzer, visitorFunc TypedVisito }) } -// Weight constants aren't well named for right now. These are just dumb values to assign heuristic weight to certain -// query elements -const ( - Weight1 int64 = iota + 1 - Weight2 - Weight3 -) - -type ComplexityMeasure struct { - Weight int64 - - numPatterns int64 - numProjections int64 - nodeLookupKinds map[string]graph.Kinds -} - -func (s *ComplexityMeasure) onFunctionInvocation(_ *model.WalkStack, node *model.FunctionInvocation) error { - switch node.Name { - case "collect": - // Collect will force an eager aggregation - s.Weight += Weight2 - - case "type": - // Calling for a relationship's type is highly likely to be inefficient and should add weight - s.Weight += Weight2 - } - - return nil -} - -func (s *ComplexityMeasure) onQuantifier(_ *model.WalkStack, _ *model.Quantifier) error { - // Quantifier expressions may increase the size of an inline projection to apply its contained filter and should - // be weighted - s.Weight += Weight1 - return nil -} - -func (s *ComplexityMeasure) onFilterExpression(_ *model.WalkStack, _ *model.FilterExpression) error { - // Filter expressions convert directly into a filter in the query plan which may or may not take advantage - // of indexes and should be weighted accordingly - s.Weight += Weight1 - return nil -} - -func (s *ComplexityMeasure) onKindMatcher(_ *model.WalkStack, node *model.KindMatcher) error { - switch typedReference := node.Reference.(type) { - case *model.Variable: - // This kind matcher narrows a node reference's kind and will result in an indexed lookup - s.nodeLookupKinds[typedReference.Symbol] = s.nodeLookupKinds[typedReference.Symbol].Add(node.Kinds...) - } - - return nil -} - -func (s *ComplexityMeasure) onPatternPart(_ *model.WalkStack, node *model.PatternPart) error { - // All pattern parts incur a compounding weight - s.numPatterns += 1 - s.Weight += s.numPatterns - - if node.ShortestPathPattern { - // Rendering the shortest path, while cheaper than rendering all shortest paths, still could incur a large - // search cost - s.Weight += Weight1 - } - - if node.AllShortestPathsPattern { - // Rendering all shortest paths could result in a large search - s.Weight += Weight2 - } - - return nil -} - -func (s *ComplexityMeasure) onSortItem(_ *model.WalkStack, _ *model.SortItem) error { - // Sorting incurs a weight since it will change how the projection is materialized - s.Weight += Weight1 - return nil -} - -func (s *ComplexityMeasure) onProjection(_ *model.WalkStack, node *model.Projection) error { - // We want to capture the cost of additional inline projections so ignore the first projection - s.Weight += s.numProjections - s.numProjections += 1 - - if node.Distinct { - // Distinct incurs a weight since it will change how the projection is materialized - s.Weight += Weight1 - } - - return nil -} - -func (s *ComplexityMeasure) onPartialComparison(_ *model.WalkStack, node *model.PartialComparison) error { - switch node.Operator { - case model.OperatorRegexMatch: - // Regular expression matching incurs a weight since it can be far more involved than any of the other - // string operators - s.Weight += Weight1 - } - - return nil -} - -func (s *ComplexityMeasure) onNodePattern(_ *model.WalkStack, node *model.NodePattern) error { - if node.Binding == nil { - if len(node.Kinds) == 0 { - // Unlabeled, unbound nodes will incur a lookup of all nodes in the graph - s.Weight += Weight2 - } - } else if nodePatternBinding, typeOK := node.Binding.(*model.Variable); !typeOK { - return fmt.Errorf("expected variable for node pattern binding but got: %T", node.Binding) - } else { - nodeLookupKinds, hasBinding := s.nodeLookupKinds[nodePatternBinding.Symbol] - - if !hasBinding { - nodeLookupKinds = node.Kinds - } else { - nodeLookupKinds = nodeLookupKinds.Add(node.Kinds...) - } - - // Track this node pattern to see if any subsequent expressions will narrow its kind matchers - s.nodeLookupKinds[nodePatternBinding.Symbol] = nodeLookupKinds - } - - return nil -} - -func (s *ComplexityMeasure) onRelationshipPattern(_ *model.WalkStack, node *model.RelationshipPattern) error { - numKindMatchers := len(node.Kinds) - - // All relationship lookups incur a weight - s.Weight += Weight1 - - if node.Direction == graph.DirectionBoth { - // Bidirectional searches add weight - s.Weight += Weight1 - } - - if numKindMatchers == 0 { - // If user is expanding all relationship types add weight - s.Weight += Weight2 - } - - if node.Range != nil { - if numKindMatchers > 2 { - // If we're matching on more than two relationship types add weight - s.Weight += Weight1 - } - - if node.Range.StartIndex != nil && *node.Range.StartIndex > 1 { - // Patterns that must have a floor greater than 1 may result in large expansions - s.Weight += Weight1 - } - - if node.Range.EndIndex == nil { - // Unbounded range literals are likely to result in large expansions - s.Weight += Weight3 - } else if *node.Range.EndIndex > 1 { - // Patterns that must have a ceiling greater than 1 may result in large expansions - s.Weight += Weight1 - } - } - - return nil -} - -func (s *ComplexityMeasure) onExit() { - for _, kindMatchers := range s.nodeLookupKinds { - if len(kindMatchers) == 0 { - // Unlabeled nodes will incur a lookup of all nodes in the graph - s.Weight += Weight2 - } - } -} - func QueryComplexity(query *model.RegularQuery) (*ComplexityMeasure, error) { var ( analyzer = &Analyzer{} @@ -254,11 +78,18 @@ func QueryComplexity(query *model.RegularQuery) (*ComplexityMeasure, error) { WithVisitor(analyzer, measure.onFunctionInvocation) WithVisitor(analyzer, measure.onKindMatcher) WithVisitor(analyzer, measure.onQuantifier) - WithVisitor(analyzer, measure.onFilterExpression) WithVisitor(analyzer, measure.onSortItem) WithVisitor(analyzer, measure.onPartialComparison) + WithVisitor(analyzer, measure.onWhere) + + // Mutations + WithVisitor(analyzer, measure.onCreate) + WithVisitor(analyzer, measure.onDelete) + WithVisitor(analyzer, measure.onMerge) + WithVisitor(analyzer, measure.onRemove) + WithVisitor(analyzer, measure.onSet) - if err := analyzer.Analyze(query); err != nil { + if err := analyzer.analyze(query); err != nil { return nil, err } diff --git a/packages/go/cypher/analyzer/analyzer_test.go b/packages/go/cypher/analyzer/analyzer_test.go index 166431df2d..7fe4bcd924 100644 --- a/packages/go/cypher/analyzer/analyzer_test.go +++ b/packages/go/cypher/analyzer/analyzer_test.go @@ -27,30 +27,28 @@ import ( func TestQueryComplexity(t *testing.T) { // Walk through all positive test cases to ensure that the walker can handle all supported types - for _, testCase := range test.LoadFixture(t, test.PositiveTestCases).RunnableCases() { - t.Run(testCase.Name, func(t *testing.T) { - // Only bother with the string match tests - if testCase.Type == test.TypeStringMatch { - var ( - details = test.UnmarshallTestCaseDetails[test.StringMatchTest](t, testCase) - parseContext = frontend.NewContext() - queryModel, parseErr = frontend.ParseCypher(parseContext, details.Query) - ) - - if parseErr != nil { - t.Fatalf("Parser errors: %s", parseErr.Error()) - } - - if details.Complexity != nil { - complexity, analyzerErr := analyzer.QueryComplexity(queryModel) - - if analyzerErr != nil { - t.Fatalf("Analyzer errors: %s", parseErr.Error()) + for _, caseLoad := range []string{test.PositiveTestCases, test.MutationTestCases} { + for _, testCase := range test.LoadFixture(t, caseLoad).RunnableCases() { + t.Run(testCase.Name, func(t *testing.T) { + // Only bother with the string match tests + if testCase.Type == test.TypeStringMatch { + var ( + details = test.UnmarshallTestCaseDetails[test.StringMatchTest](t, testCase) + parseContext = frontend.NewContext() + queryModel, parseErr = frontend.ParseCypher(parseContext, details.Query) + ) + + if parseErr != nil { + t.Fatalf("Parser errors: %s", parseErr.Error()) } - require.Equal(t, *details.Complexity, complexity.Weight) + if details.Complexity != nil { + complexity, analyzerErr := analyzer.QueryComplexity(queryModel) + require.Nil(t, analyzerErr) + require.Equal(t, *details.Complexity, complexity.Weight) + } } - } - }) + }) + } } } diff --git a/packages/go/cypher/analyzer/measure.go b/packages/go/cypher/analyzer/measure.go new file mode 100644 index 0000000000..fa3f77ac0c --- /dev/null +++ b/packages/go/cypher/analyzer/measure.go @@ -0,0 +1,271 @@ +// Copyright 2024 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 analyzer + +import ( + "fmt" + + "github.com/specterops/bloodhound/cypher/model" + "github.com/specterops/bloodhound/dawgs/graph" +) + +// Weight constants aren't well named for right now. These are just dumb values to assign heuristic weight to certain +// query elements +const ( + weight1 int64 = 1 + weight2 int64 = 2 + weight3 int64 = 3 + weightHeavy int64 = 7 + weightMaxComplexity int64 = 50 +) + +type ComplexityMeasure struct { + Weight int64 + + hasWhere bool + hasPatternProperties bool + hasLimit bool + isCreate bool + + numPatterns int64 + numProjections int64 + nodeLookupKinds map[string]graph.Kinds +} + +func (s *ComplexityMeasure) onCreate(_ *model.WalkStack, _ *model.Create) error { + // Let's add 1 per create + s.Weight += weight1 + s.isCreate = true + + return nil +} + +func (s *ComplexityMeasure) onDelete(_ *model.WalkStack, node *model.Delete) error { + // Base weight for delete is 3, if detach is specified, we give a heavy weight on top to account + // for the extra complexity of deleting relationships + s.Weight += weight3 + if node.Detach { + s.Weight += weightHeavy + } + + return nil +} + +func (s *ComplexityMeasure) onExit() { + var hasKindMatcher bool + + for _, kindMatchers := range s.nodeLookupKinds { + if len(kindMatchers) == 0 { + // Unlabeled nodes will incur a lookup of all nodes in the graph + s.Weight += weight2 + } else { + hasKindMatcher = true + } + } + + // TODO: This is a little gross and needs to be refactored + if !hasKindMatcher && !s.hasPatternProperties && !s.hasWhere && !s.hasLimit && !s.isCreate { + s.Weight += weightMaxComplexity + } +} + +func (s *ComplexityMeasure) onFunctionInvocation(_ *model.WalkStack, node *model.FunctionInvocation) error { + switch node.Name { + case "collect": + // Collect will force an eager aggregation + s.Weight += weight2 + + case "type": + // Calling for a relationship's type is highly likely to be inefficient and should add weight + s.Weight += weight2 + } + + return nil +} + +func (s *ComplexityMeasure) onKindMatcher(_ *model.WalkStack, node *model.KindMatcher) error { + switch typedReference := node.Reference.(type) { + case *model.Variable: + // This kind matcher narrows a node reference's kind and will result in an indexed lookup + s.nodeLookupKinds[typedReference.Symbol] = s.nodeLookupKinds[typedReference.Symbol].Add(node.Kinds...) + } + + return nil +} + +func (s *ComplexityMeasure) onMerge(_ *model.WalkStack, node *model.Merge) error { + // Let's add 1 per merge action + s.Weight += weight1 * int64(len(node.MergeActions)) + + return nil +} + +func (s *ComplexityMeasure) onNodePattern(_ *model.WalkStack, node *model.NodePattern) error { + if node.Binding == nil { + if len(node.Kinds) == 0 { + // Unlabeled, unbound nodes will incur a lookup of all nodes in the graph + s.Weight += weight2 + } + } else if nodePatternBinding, typeOK := node.Binding.(*model.Variable); !typeOK { + return fmt.Errorf("expected variable for node pattern binding but got: %T", node.Binding) + } else { + nodeLookupKinds, hasBinding := s.nodeLookupKinds[nodePatternBinding.Symbol] + + if !hasBinding { + nodeLookupKinds = node.Kinds + } else { + nodeLookupKinds = nodeLookupKinds.Add(node.Kinds...) + } + + // Track this node pattern to see if any subsequent expressions will narrow its kind matchers + s.nodeLookupKinds[nodePatternBinding.Symbol] = nodeLookupKinds + } + + if node.Properties != nil { + s.hasPatternProperties = true + } + + return nil +} + +func (s *ComplexityMeasure) onPartialComparison(_ *model.WalkStack, node *model.PartialComparison) error { + switch node.Operator { + case model.OperatorRegexMatch: + // Regular expression matching incurs a weight since it can be far more involved than any of the other + // string operators + s.Weight += weight1 + } + + return nil +} + +func (s *ComplexityMeasure) onPatternPart(_ *model.WalkStack, node *model.PatternPart) error { + // All pattern parts incur a compounding weight + s.numPatterns += 1 + s.Weight += s.numPatterns + + if node.ShortestPathPattern { + // Rendering the shortest path, while cheaper than rendering all shortest paths, still could incur a large + // search cost + s.Weight += weight1 + } + + if node.AllShortestPathsPattern { + // Rendering all shortest paths could result in a large search + s.Weight += weight2 + } + + return nil +} + +func (s *ComplexityMeasure) onProjection(_ *model.WalkStack, node *model.Projection) error { + // We want to capture the cost of additional inline projections so ignore the first projection + s.Weight += s.numProjections + s.numProjections += 1 + + if node.Distinct { + // Distinct incurs a weight since it will change how the projection is materialized + s.Weight += weight1 + } + + if node.Limit != nil { + s.hasLimit = true + } + + return nil +} + +func (s *ComplexityMeasure) onQuantifier(_ *model.WalkStack, _ *model.Quantifier) error { + // Quantifier expressions may increase the size of an inline projection to apply its contained filter and should + // be weighted + s.Weight += weight1 + return nil +} + +func (s *ComplexityMeasure) onRelationshipPattern(_ *model.WalkStack, node *model.RelationshipPattern) error { + numKindMatchers := len(node.Kinds) + + // All relationship lookups incur a weight + s.Weight += weight1 + + if node.Direction == graph.DirectionBoth { + // Bidirectional searches add weight + s.Weight += weight1 + } + + if numKindMatchers == 0 { + // If user is expanding all relationship types add weight + s.Weight += weight2 + } + + if node.Range != nil { + if numKindMatchers > 2 { + // If we're matching on more than two relationship types add weight + s.Weight += weight1 + } + + if node.Range.StartIndex != nil && *node.Range.StartIndex > 1 { + // Patterns that must have a floor greater than 1 may result in large expansions + s.Weight += weight1 + } + + if node.Range.EndIndex == nil { + // Unbounded range literals are likely to result in large expansions + s.Weight += weight3 + } else if *node.Range.EndIndex > 1 { + // Patterns that must have a ceiling greater than 1 may result in large expansions + s.Weight += weight1 + } + } + + if node.Properties != nil { + s.hasPatternProperties = true + } + + if len(node.Kinds) != 0 { + s.hasPatternProperties = true + } + + return nil +} + +func (s *ComplexityMeasure) onRemove(_ *model.WalkStack, node *model.Remove) error { + // Let's add 1 per remove + s.Weight += weight1 + + return nil +} + +func (s *ComplexityMeasure) onSet(_ *model.WalkStack, node *model.Set) error { + // Let's add 1 per set + s.Weight += weight1 + + return nil +} + +func (s *ComplexityMeasure) onSortItem(_ *model.WalkStack, _ *model.SortItem) error { + // Sorting incurs a weight since it will change how the projection is materialized + s.Weight += weight1 + return nil +} + +func (s *ComplexityMeasure) onWhere(_ *model.WalkStack, _ *model.Where) error { + // Filters in the query plan may or may not take advantage of indexes and should be weighted accordingly + s.Weight += weight1 + s.hasWhere = true + return nil +} diff --git a/packages/go/cypher/backend/cypher/format_test.go b/packages/go/cypher/backend/cypher/format_test.go index 9024153908..bc9cd5429c 100644 --- a/packages/go/cypher/backend/cypher/format_test.go +++ b/packages/go/cypher/backend/cypher/format_test.go @@ -18,10 +18,11 @@ package cypher_test import ( "bytes" + "testing" + "github.com/specterops/bloodhound/cypher/backend/cypher" "github.com/specterops/bloodhound/cypher/frontend" "github.com/stretchr/testify/require" - "testing" "github.com/specterops/bloodhound/cypher/test" ) @@ -41,6 +42,7 @@ func TestCypherEmitter_StripLiterals(t *testing.T) { } func TestCypherEmitter_HappyPath(t *testing.T) { + test.LoadFixture(t, test.MutationTestCases).Run(t) test.LoadFixture(t, test.PositiveTestCases).Run(t) } diff --git a/packages/go/cypher/frontend/atom.go b/packages/go/cypher/frontend/atom.go index 5ea5e42936..742c5998eb 100644 --- a/packages/go/cypher/frontend/atom.go +++ b/packages/go/cypher/frontend/atom.go @@ -19,6 +19,7 @@ package frontend import ( "github.com/specterops/bloodhound/cypher/model" "github.com/specterops/bloodhound/cypher/parser" + "strings" ) type IDInCollectionVisitor struct { @@ -136,6 +137,30 @@ func NewAtomVisitor() Visitor { return &AtomVisitor{} } +func (s *AtomVisitor) EnterOC_Parameter(ctx *parser.OC_ParameterContext) { + s.ctx.Enter(&SymbolicNameOrReservedWordVisitor{}) +} + +// extractParameterSymbol attempts to extract the symbolic representation of the parameter from the given +// OC_ParameterContext. This function expects a SymbolicNameOrReservedWordVisitor to be on the context stack. +// +// Reasoning for this is that oC_Parameter can either be oC_SymbolicName or a DecimalInteger (which is +// either a Decimal or an Integer). In the latter case the symbolic representation is stored as a token +// on the OC_ParameterContext struct. +func extractParameterSymbol(ctx *Context, cypherCtx *parser.OC_ParameterContext) string { + if symbolicName := ctx.Exit().(*SymbolicNameOrReservedWordVisitor).Name; len(symbolicName) > 0 { + return symbolicName + } + + return strings.TrimPrefix(cypherCtx.GetText(), "$") +} + +func (s *AtomVisitor) ExitOC_Parameter(ctx *parser.OC_ParameterContext) { + s.Atom = &model.Parameter{ + Symbol: extractParameterSymbol(s.ctx, ctx), + } +} + func (s *AtomVisitor) EnterOC_ParenthesizedExpression(ctx *parser.OC_ParenthesizedExpressionContext) { s.ctx.Enter(NewParenthesizedExpressionVisitor()) } diff --git a/packages/go/cypher/frontend/expression.go b/packages/go/cypher/frontend/expression.go index 30d99430b7..179d3993a7 100644 --- a/packages/go/cypher/frontend/expression.go +++ b/packages/go/cypher/frontend/expression.go @@ -1,17 +1,17 @@ // 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 frontend @@ -53,14 +53,8 @@ func (s *PropertiesVisitor) EnterOC_Parameter(ctx *parser.OC_ParameterContext) { } func (s *PropertiesVisitor) ExitOC_Parameter(ctx *parser.OC_ParameterContext) { - if symbolicName := s.ctx.Exit().(*SymbolicNameOrReservedWordVisitor).Name; symbolicName == "" { - s.Properties.Parameter = &model.Parameter{ - Symbol: strings.TrimPrefix(ctx.GetText(), "$"), - } - } else { - s.Properties.Parameter = &model.Parameter{ - Symbol: symbolicName, - } + s.Properties.Parameter = &model.Parameter{ + Symbol: extractParameterSymbol(s.ctx, ctx), } } diff --git a/packages/go/cypher/frontend/query.go b/packages/go/cypher/frontend/query.go index 41ecd362f7..ee2f91a7f0 100644 --- a/packages/go/cypher/frontend/query.go +++ b/packages/go/cypher/frontend/query.go @@ -352,8 +352,7 @@ func (s *SinglePartQueryVisitor) ExitOC_UpdatingClause(ctx *parser.OC_UpdatingCl type MultiPartQueryVisitor struct { BaseVisitor - currentPart *model.MultiPartQueryPart - Query *model.MultiPartQuery + Query *model.MultiPartQuery } func NewMultiPartQueryVisitor() *MultiPartQueryVisitor { @@ -363,18 +362,13 @@ func NewMultiPartQueryVisitor() *MultiPartQueryVisitor { } func (s *MultiPartQueryVisitor) EnterOC_ReadingClause(ctx *parser.OC_ReadingClauseContext) { - s.currentPart = model.NewMultiPartQueryPart() - s.Query.Parts = append(s.Query.Parts, s.currentPart) - s.ctx.Enter(NewReadingClauseVisitor()) } func (s *MultiPartQueryVisitor) ExitOC_ReadingClause(ctx *parser.OC_ReadingClauseContext) { - if s.currentPart != nil { - s.currentPart.AddReadingClause(s.ctx.Exit().(*ReadingClauseVisitor).ReadingClause) - } else { - s.ctx.AddErrors(ErrInvalidInput) - } + part := model.NewMultiPartQueryPart() + part.AddReadingClause(s.ctx.Exit().(*ReadingClauseVisitor).ReadingClause) + s.Query.Parts = append(s.Query.Parts, part) } func (s *MultiPartQueryVisitor) EnterOC_UpdatingClause(ctx *parser.OC_UpdatingClauseContext) { @@ -382,12 +376,10 @@ func (s *MultiPartQueryVisitor) EnterOC_UpdatingClause(ctx *parser.OC_UpdatingCl } func (s *MultiPartQueryVisitor) ExitOC_UpdatingClause(ctx *parser.OC_UpdatingClauseContext) { - if s.currentPart != nil { - s.ctx.HasMutation = true - s.currentPart.AddUpdatingClause(s.ctx.Exit().(*UpdatingClauseVisitor).UpdatingClause) - } else { - s.ctx.AddErrors(ErrInvalidInput) - } + s.ctx.HasMutation = true + part := model.NewMultiPartQueryPart() + part.AddUpdatingClause(s.ctx.Exit().(*UpdatingClauseVisitor).UpdatingClause) + s.Query.Parts = append(s.Query.Parts, part) } func (s *MultiPartQueryVisitor) EnterOC_With(ctx *parser.OC_WithContext) { @@ -395,11 +387,9 @@ func (s *MultiPartQueryVisitor) EnterOC_With(ctx *parser.OC_WithContext) { } func (s *MultiPartQueryVisitor) ExitOC_With(ctx *parser.OC_WithContext) { - if s.currentPart != nil { - s.currentPart.With = s.ctx.Exit().(*WithVisitor).With - } else { - s.ctx.AddErrors(ErrInvalidInput) - } + part := model.NewMultiPartQueryPart() + part.With = s.ctx.Exit().(*WithVisitor).With + s.Query.Parts = append(s.Query.Parts, part) } func (s *MultiPartQueryVisitor) EnterOC_SinglePartQuery(ctx *parser.OC_SinglePartQueryContext) { diff --git a/packages/go/cypher/test/cases.go b/packages/go/cypher/test/cases.go index 79bb50de1d..493a91b28b 100644 --- a/packages/go/cypher/test/cases.go +++ b/packages/go/cypher/test/cases.go @@ -1,17 +1,17 @@ // 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 test @@ -20,4 +20,5 @@ const ( PositiveTestCases = "cases/positive_tests.json" NegativeTestCases = "cases/negative_tests.json" FilteringTestCases = "cases/filtering_tests.json" + MutationTestCases = "cases/mutation_tests.json" ) diff --git a/packages/go/cypher/test/cases/mutation_tests.json b/packages/go/cypher/test/cases/mutation_tests.json new file mode 100644 index 0000000000..1d157bf23f --- /dev/null +++ b/packages/go/cypher/test/cases/mutation_tests.json @@ -0,0 +1,253 @@ +{ + "test_cases": [ + { + "name": "Multipart query with mutation", + "type": "string_match", + "details": { + "query": "match (s:Ship {name: 'Nebuchadnezzar'}) with s as ship merge p = (c:Crew {name: 'Neo'})<-[:CrewOf]->(ship) set c.title = 'The One' return p", + "complexity": 9 + } + }, + { + "name": "Merge labelled node(s) to set a property", + "type": "string_match", + "details": { + "query": "merge (p:Program) set p.name = 'Smith' return p", + "complexity": 2 + } + }, + { + "name": "Merge labelled node(s) and if not created set a property", + "type": "string_match", + "details": { + "query": "merge (p:Program) on match set p.name = 'Smith' return p", + "complexity": 3 + } + }, + { + "name": "Merge labelled node(s) and if created set a property", + "type": "string_match", + "details": { + "query": "merge (p:Human) on create set p.born = 'now' return p", + "complexity": 3 + } + }, + { + "name": "Merge labelled node(s) and do multiple merge actions and set a property", + "type": "string_match", + "details": { + "query": "merge (p:Sentinel) on create set p.emp = 'active' on match set p.emp = 'charged' set p.hunting = true return p", + "complexity": 6 + } + }, + { + "name": "JD's Create User Example", + "type": "string_match", + "details": { + "query": "merge (x:Base {objectid: ''}) set x:User, x.name = 'BOB@TEST.LAB' set x += {arr: ['abc', 'def', 'ghi']} return x", + "complexity": 3 + } + }, + { + "name": "JD's Create Edges Example", + "type": "string_match", + "details": { + "query": "match (x) match (y) merge (x)-[:Edge]->(y)", + "complexity": 11 + } + }, + { + "name": "Create node", + "type": "string_match", + "details": { + "query": "create (u) return u", + "complexity": 4 + } + }, + { + "name": "Create node with label", + "type": "string_match", + "details": { + "query": "create (u:Human {name: Neo}) return u", + "complexity": 2 + } + }, + { + "name": "Create node with decimal properties parameter", + "type": "string_match", + "details": { + "query": "create (a:Label $1) return a", + "complexity": 2 + } + }, + { + "name": "Create node with named properties parameter", + "type": "string_match", + "details": { + "query": "create (a:Label $named) return a", + "complexity": 2 + } + }, + { + "name": "Create multiple nodes", + "type": "string_match", + "details": { + "query": "create (a:Label {p: '1234'}), (b:Other) return a, b", + "complexity": 4 + } + }, + { + "name": "Create relationship", + "type": "string_match", + "details": { + "query": "create p = (:Label {p: '1234'})-[:Link {r: 1234}]->(b {p: '4321'}) return p", + "complexity": 5 + } + }, + { + "name": "Create relationship with decimal properties parameter", + "type": "string_match", + "details": { + "query": "create p = (:Label {p: '1234'})-[:Link $1]->(b {p: '4321'}) return p", + "complexity": 5 + } + }, + { + "name": "Create relationship with named properties parameter", + "type": "string_match", + "details": { + "query": "create p = (:Label {p: '1234'})-[:Link $named]->(b {p: '4321'}) return p", + "complexity": 5 + } + }, + { + "name": "Create relationship with matching", + "type": "string_match", + "details": { + "query": "match (a), (b) where a.name = 'a' and b.linked = id(a) create p = (a)-[:Linked]->(b) return p", + "complexity": 13 + } + }, + { + "name": "Set node properties", + "type": "string_match", + "details": { + "query": "match (n:Human {name: Neo}) set n.one = true return n", + "complexity": 2 + } + }, + { + "name": "Set node properties with map", + "type": "string_match", + "details": { + "query": "match (b:Thing) set b += {prop1: '123', lprop: [1, [2, 3, 4], {a: 1234}]} return b", + "matcher": "match \\(b:Thing\\) set b \\+= \\{(prop1: '123', lprop: \\[1, \\[2, 3, 4\\], \\{a: 1234}]|lprop: \\[1, \\[2, 3, 4\\], \\{a: 1234}], prop1: '123')} return b", + "complexity": 2 + } + }, + { + "name": "Set node property to null", + "type": "string_match", + "details": { + "query": "match (b:Thing) set b.prop = null return b", + "complexity": 2 + } + }, + { + "name": "Set node property to other node property", + "type": "string_match", + "details": { + "query": "match (a:User), (b:Admin) set a.prop = b.prop", + "complexity": 4 + } + }, + { + "name": "Set node labels", + "type": "string_match", + "details": { + "query": "match (b:Thing) set b:Other return b", + "complexity": 2 + } + }, + { + "name": "Set multiple node properties", + "type": "string_match", + "details": { + "query": "match (b:Thing) set b.name = '123', b.other = '123' return b", + "complexity": 2 + } + }, + { + "name": "Set multiple node properties with multiple updating clauses", + "type": "string_match", + "details": { + "query": "match (b:Thing) set b.name = '123' set b:Label return b", + "complexity": 3 + } + }, + { + "name": "Remove node properties", + "type": "string_match", + "details": { + "query": "match (b:Thing) remove b.name return b", + "complexity": 2 + } + }, + { + "name": "Remove multiple node properties", + "type": "string_match", + "details": { + "query": "match (b:Thing) remove b.name, b.other return b", + "complexity": 2 + } + }, + { + "name": "Remove multiple node properties with multiple updating clauses", + "type": "string_match", + "details": { + "query": "match (b:Thing) remove b.name remove b:Label return b", + "complexity": 3 + } + }, + { + "name": "Remove node properties from node pattern", + "type": "string_match", + "details": { + "query": "match (a:Agent {name: Smith}) remove a.trapped return a", + "complexity": 2 + } + }, + { + "name": "Delete node", + "type": "string_match", + "details": { + "query": "match (b:Thing) delete b return b", + "complexity": 4 + } + }, + { + "name": "Delete node with node pattern", + "type": "string_match", + "details": { + "query": "match (u:Human {name: Dozer}) delete u", + "complexity": 4 + } + }, + { + "name": "Delete detach node", + "type": "string_match", + "details": { + "query": "match (b:Thing) detach delete b return b", + "complexity": 11 + } + }, + { + "name": "Delete detach nodes", + "type": "string_match", + "details": { + "query": "match (a:Thing1), (b:Thing2) detach delete a, b return b", + "complexity": 13 + } + } + ] +} diff --git a/packages/go/cypher/test/cases/negative_tests.json b/packages/go/cypher/test/cases/negative_tests.json index 86c3f14c35..bf5f3fa147 100644 --- a/packages/go/cypher/test/cases/negative_tests.json +++ b/packages/go/cypher/test/cases/negative_tests.json @@ -5,11 +5,12 @@ "type": "negative_case", "details": { "queries": [ - "MERGE () WITH * RETURN DISTINCT * UNION RETURN * UNION RETURN *", - "WITH * SKIP 0 RETURN DISTINCT *" + "MATCH MERGE", + "MERGE MATCH" ], "error_matchers": [ - "invalid input" + "line 1:6 no viable alternative at input 'MATCH MERGE'", + "line 1:6 no viable alternative at input 'MERGE MATCH'" ] } }, diff --git a/packages/go/cypher/test/cases/positive_tests.json b/packages/go/cypher/test/cases/positive_tests.json index 9445649406..6098e2b7f2 100644 --- a/packages/go/cypher/test/cases/positive_tests.json +++ b/packages/go/cypher/test/cases/positive_tests.json @@ -4,7 +4,7 @@ "name": "Match all nodes in the graph", "type": "string_match", "details": { - "query": "match (a) return a", + "query": "match (a) return a limit 5", "complexity": 3 } }, @@ -29,7 +29,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.details.name = 'Tom Hanks' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -45,7 +45,7 @@ "type": "string_match", "details": { "query": "match (g:GPO) optional match (g)-[r1:GPLink {enforced: false}]->(container1) with g, container1 optional match (g)-[r2:GPLink {enforced: true}]->(container2) with g, container1, container2 optional match p1 = (g)-[r1:GPLink]->(container1)-[r2:Contains*1..]->(n1:Computer) where none(x in nodes(p1) where x.blocksinheritance = true and labels(x) = 'OU') with g, p1, container2, n1 optional match p2 = (g)-[r1:GPLink]->(container2)-[r2:Contains*1..]->(n2:Computer) return p1, p2", - "complexity": 39 + "complexity": 40 } }, { @@ -73,6 +73,22 @@ "complexity": 1 } }, + { + "name": "Run query with parameters", + "type": "string_match", + "details": { + "query": "match (p:Person) where p.name = $name return p.born.year", + "complexity": 2 + } + }, + { + "name": "Run query with complex parameters", + "type": "string_match", + "details": { + "query": "match (p:Person {value: $test}) where p.name = $1 and p.other in $array return p.name, p.born.year", + "complexity": 2 + } + }, { "name": "Retrieve multiple node properties", "type": "string_match", @@ -86,7 +102,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.name = 'Tom Hanks' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -94,7 +110,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.age < 50 return p", - "complexity": 1 + "complexity": 2 } }, { @@ -102,7 +118,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.age > 50 return p", - "complexity": 1 + "complexity": 2 } }, { @@ -110,7 +126,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.age <= 50 return p", - "complexity": 1 + "complexity": 2 } }, { @@ -118,7 +134,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.age >= 50 return p", - "complexity": 1 + "complexity": 2 } }, { @@ -126,7 +142,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.name <> 'Tom Hanks' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -134,7 +150,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where (p.fname = 'Tom' or p.fname = 'Brad') return p", - "complexity": 1 + "complexity": 2 } }, { @@ -142,7 +158,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.fname = 'Tom' and p.lname = 'Hanks' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -150,7 +166,7 @@ "type": "string_match", "details": { "query": "match (p:Person)-[:ACTED_IN]->(m:Movie) where p.name = 'Tom Hanks' return m", - "complexity": 2 + "complexity": 3 } }, { @@ -198,7 +214,7 @@ "type": "string_match", "details": { "query": "match (p)-[:ACTED_IN]->(m) where p:Person and m:Movie and m.title = 'The Matrix' return p.name", - "complexity": 2 + "complexity": 3 } }, { @@ -206,7 +222,7 @@ "type": "string_match", "details": { "query": "match (p:Person)-[:ACTED_IN]->(m:Movie) where 2000 < m.released < 2003 and 100 > m.last < 200 return p.name", - "complexity": 2 + "complexity": 3 } }, { @@ -222,7 +238,7 @@ "type": "string_match", "details": { "query": "match (n) where n.doesThisPropertyExist is not null return n", - "complexity": 3 + "complexity": 4 } }, { @@ -230,7 +246,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.name starts with 'tom' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -238,7 +254,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.name ends with 'hanks' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -246,7 +262,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.name contains 'tom h' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -254,7 +270,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where toLower(p.name) starts with 'tom' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -262,7 +278,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where toUpper(p.name) starts with 'tom' return p", - "complexity": 1 + "complexity": 2 } }, { @@ -270,7 +286,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.born in [1965, 1970, 1975] return p.name, p.born", - "complexity": 1 + "complexity": 2 } }, { @@ -278,7 +294,7 @@ "type": "string_match", "details": { "query": "match (p:Person) where p.name in [\"tom\", \"tommy\", \"thomas\"] return p.name, p.born", - "complexity": 1 + "complexity": 2 } }, { @@ -286,7 +302,7 @@ "type": "string_match", "details": { "query": "match (p:Person)-[r:ACTED_IN]->(m:Movie) where 'Neo' in r.roles return p.name", - "complexity": 2 + "complexity": 3 } }, { @@ -309,8 +325,8 @@ "name": "Specify alias in return clause with `AS`", "type": "string_match", "details": { - "query": "match (n) return n.property as renamedProperty", - "complexity": 3 + "query": "match (n:Person) return n.property as renamedProperty", + "complexity": 1 } }, { @@ -410,7 +426,7 @@ } }, { - "name": "Aggregation using collect() to create a list", + "name": "Aggregation using collect() to return a list", "type": "string_match", "details": { "query": "match (p:Person)-[:ACTED_IN]->(m:Movie) return p.name, collect(m.title)", @@ -422,7 +438,7 @@ "type": "string_match", "details": { "query": "match (p:Person)-[:ACTED_IN]->(m:Movie) where m.year = 1920 return collect(distinct (m.title))", - "complexity": 4 + "complexity": 5 } }, { @@ -430,7 +446,7 @@ "type": "string_match", "details": { "query": "match (p:Person)-[:ACTED_IN]->(m:Movie) where p.name = 'tom cruise' return collect(m) as tomCruiseMovies", - "complexity": 4 + "complexity": 5 } }, { @@ -438,15 +454,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((p1:Person)<-[*]->(p2:Person)) where p1.name = 'tom' and p2.name = 'jerry' return p", - "complexity": 9 - } - }, - { - "name": "Find nodes with property", - "type": "string_match", - "details": { - "query": "match (b) where b.name is not null return b", - "complexity": 3 + "complexity": 10 } }, { @@ -454,7 +462,7 @@ "type": "string_match", "details": { "query": "match (b) where b.name is not null return b", - "complexity": 3 + "complexity": 4 } }, { @@ -462,7 +470,7 @@ "type": "string_match", "details": { "query": "match (b) where (b)<-[]->() return b", - "complexity": 9 + "complexity": 10 } }, { @@ -470,188 +478,19 @@ "type": "string_match", "details": { "query": "match (b) where not ((b)<-[]->()) return b", - "complexity": 9 - } - }, - { - "name": "Set node properties", - "type": "string_match", - "details": { - "query": "match (b) set b.name = '123' return b", - "complexity": 3 - } - }, - { - "name": "Set node properties with map", - "type": "string_match", - "details": { - "query": "match (b) set b += {prop1: '123', lprop: [1, [2, 3, 4], {a: 1234}]} return b", - "matcher": "match \\(b\\) set b \\+= \\{(prop1: '123', lprop: \\[1, \\[2, 3, 4\\], \\{a: 1234}]|lprop: \\[1, \\[2, 3, 4\\], \\{a: 1234}], prop1: '123')} return b", - "complexity": 3 - } - }, - { - "name": "Set node property to null", - "type": "string_match", - "details": { - "query": "match (b) set b.prop = null return b", - "complexity": 3 - } - }, - { - "name": "Set node property to other node property", - "type": "string_match", - "details": { - "query": "match (a), (b) set a.prop = b.prop", - "complexity": 7 - } - }, - { - "name": "Set node labels", - "type": "string_match", - "details": { - "query": "match (b) set b:Other return b", - "complexity": 3 - } - }, - { - "name": "Set multiple node properties", - "type": "string_match", - "details": { - "query": "match (b) set b.name = '123', b.other = '123' return b", - "complexity": 3 - } - }, - { - "name": "Set multiple node properties with multiple updating clauses", - "type": "string_match", - "details": { - "query": "match (b) set b.name = '123' set b:Label return b", - "complexity": 3 - } - }, - { - "name": "Delete node", - "type": "string_match", - "details": { - "query": "match (b) delete b return b", - "complexity": 3 - } - }, - { - "name": "Delete detach node", - "type": "string_match", - "details": { - "query": "match (b) detach delete b return b", - "complexity": 3 - } - }, - { - "name": "Delete detach nodes", - "type": "string_match", - "details": { - "query": "match (a), (b) detach delete a, b return b", - "complexity": 7 - } - }, - { - "name": "Create node", - "type": "string_match", - "details": { - "query": "create (a:Label {p: '1234'}) return a", - "complexity": 1 - } - }, - { - "name": "Create node with decimal properties parameter", - "type": "string_match", - "details": { - "query": "create (a:Label $1) return a", - "complexity": 1 - } - }, - { - "name": "Create node with named properties parameter", - "type": "string_match", - "details": { - "query": "create (a:Label $named) return a", - "complexity": 1 - } - }, - { - "name": "Create multiple nodes", - "type": "string_match", - "details": { - "query": "create (a:Label {p: '1234'}), (b:Other) return a, b", - "complexity": 3 - } - }, - { - "name": "Create relationship", - "type": "string_match", - "details": { - "query": "create p = (:Label {p: '1234'})-[:Link {r: 1234}]->(b {p: '4321'}) return p", - "complexity": 4 - } - }, - { - "name": "Create relationship with decimal properties parameter", - "type": "string_match", - "details": { - "query": "create p = (:Label {p: '1234'})-[:Link $1]->(b {p: '4321'}) return p", - "complexity": 4 - } - }, - { - "name": "Create relationship with named properties parameter", - "type": "string_match", - "details": { - "query": "create p = (:Label {p: '1234'})-[:Link $named]->(b {p: '4321'}) return p", - "complexity": 4 - } - }, - { - "name": "Create relationship with matching", - "type": "string_match", - "details": { - "query": "match (a), (b) where a.name = 'a' and b.linked = id(a) create p = (a)-[:Linked]->(b) return p", - "complexity": 11 - } - }, - { - "name": "Remove node properties", - "type": "string_match", - "details": { - "query": "match (b) remove b.name return b", - "complexity": 3 - } - }, - { - "name": "Remove multiple node properties", - "type": "string_match", - "details": { - "query": "match (b) remove b.name, b.other return b", - "complexity": 3 - } - }, - { - "name": "Remove multiple node properties with multiple updating clauses", - "type": "string_match", - "details": { - "query": "match (b) remove b.name remove b:Label return b", - "complexity": 1 + "complexity": 10 } }, { - "name": "Single nodes", + "name": "Single node pattern", "type": "string_match", "details": { - "query": "match (b) return b", + "query": "match (b) return b limit 5", "complexity": 3 } }, { - "name": "Multiple nodes", + "name": "Multiple node patterns", "type": "string_match", "details": { "query": "match (a), (b {prop: a.name}) return a, b", @@ -663,7 +502,7 @@ "type": "string_match", "details": { "query": "match (n) where n.indexed >= 1 and n.other_1 = 2 return n", - "complexity": 3 + "complexity": 4 } }, { @@ -671,7 +510,7 @@ "type": "string_match", "details": { "query": "match (n) where n.indexed >= 1 and n.other_1 = 2 and n.other_2 = 3 return n", - "complexity": 3 + "complexity": 4 } }, { @@ -679,7 +518,7 @@ "type": "string_match", "details": { "query": "match (n) where n.indexed >= 1 and (n.other_1 = 2 or n.other_2 = 3) return n", - "complexity": 3 + "complexity": 4 } }, { @@ -687,7 +526,7 @@ "type": "string_match", "details": { "query": "match (n) where (n.indexed >= 1 or n.other_1 = 2) return n", - "complexity": 3 + "complexity": 4 } }, { @@ -695,7 +534,7 @@ "type": "string_match", "details": { "query": "match (n) where (n.indexed >= 1 or n.other_1 = 2 or n.other_2 = 3) return n", - "complexity": 3 + "complexity": 4 } }, { @@ -703,7 +542,7 @@ "type": "string_match", "details": { "query": "match (n) where n.name starts with '123' return n", - "complexity": 3 + "complexity": 4 } }, { @@ -711,7 +550,7 @@ "type": "string_match", "details": { "query": "match (n) where n.name contains '123' return n", - "complexity": 3 + "complexity": 4 } }, { @@ -719,7 +558,7 @@ "type": "string_match", "details": { "query": "match (n) where n.name ends with '123' return n", - "complexity": 3 + "complexity": 4 } }, { @@ -727,7 +566,7 @@ "type": "string_match", "details": { "query": "match (n) where id(n) = 1 return n", - "complexity": 3 + "complexity": 4 } }, { @@ -735,23 +574,23 @@ "type": "string_match", "details": { "query": "match (n)-[:NestedEdge*]->() where id(n) = 1 return n", - "complexity": 9 + "complexity": 10 } }, { - "name": "Match patterns with range literal", + "name": "Match patterns with range literal with at least one edge", "type": "string_match", "details": { "query": "match (n)-[:NestedEdge*1..]->() where id(n) = 1 return n", - "complexity": 9 + "complexity": 10 } }, { - "name": "Match patterns with range literal", + "name": "Match patterns with range literal with 1 to 2 edges", "type": "string_match", "details": { "query": "match (n)-[:NestedEdge*1..2]->() where id(n) = 1 return n", - "complexity": 7 + "complexity": 8 } }, { @@ -759,15 +598,15 @@ "type": "string_match", "details": { "query": "match (n {property: true})<-[r {property: n.name}]-(s)-[v]->() where n.indexed = false return n, r.other", - "complexity": 13 + "complexity": 14 } }, { "name": "Match distinct", "type": "string_match", "details": { - "query": "match (n) return distinct n", - "complexity": 4 + "query": "match (n:Person) return distinct n", + "complexity": 2 } }, { @@ -782,8 +621,8 @@ "name": "Match order by", "type": "string_match", "details": { - "query": "match (n) return n order by n.name asc, n.other desc", - "complexity": 5 + "query": "match (n:Person) return n order by n.name asc, n.other desc", + "complexity": 3 } }, { @@ -791,7 +630,7 @@ "type": "string_match", "details": { "query": "match p = (n:Group)<-[:MemberOf*1..]-(m) where n.objectid =~ '(?i)S-1-5-.*-512' return p", - "complexity": 8 + "complexity": 9 } }, { @@ -815,7 +654,7 @@ "type": "string_match", "details": { "query": "match p = (n:User)-[:MemberOf]->(m:Group) where n.domain = 'DOMAIN.PAIN' and m.domain <> n.domain return p", - "complexity": 2 + "complexity": 3 } }, { @@ -823,7 +662,7 @@ "type": "string_match", "details": { "query": "match p = (n:Group {domain: 'DOMAIN.PAIN'})-[:MemberOf]->(m:Group) where m.domain <> n.domain and n.name <> m.name return p", - "complexity": 2 + "complexity": 3 } }, { @@ -847,7 +686,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((g:Group {name: 'DOMAIN USERS@DOMAIN.PAIN'})-[*1..]->(n {highvalue: true})) where g <> n return p", - "complexity": 10 + "complexity": 11 } }, { @@ -855,7 +694,7 @@ "type": "string_match", "details": { "query": "match p = allShortestPaths((g:Group {name: 'DOMAIN USERS@DOMAIN.PAIN'})-[:CanRDP]->(c:Computer)) where not (c.operatingsystem contains 'Server') return p", - "complexity": 4 + "complexity": 5 } }, { @@ -863,7 +702,7 @@ "type": "string_match", "details": { "query": "match p = (g:Group {name: 'DOMAIN USERS@DOMAIN.PAIN'})-[:CanRDP]->(c:Computer) where not (c.operatingsystem contains 'Server') return p", - "complexity": 2 + "complexity": 3 } }, { @@ -871,7 +710,7 @@ "type": "string_match", "details": { "query": "match p = (g:Group {name: 'DOMAIN USERS@DOMAIN.PAIN'})-[:CanRDP]->(c:Computer) where c.operatingsystem contains 'Server' return p", - "complexity": 2 + "complexity": 3 } }, { @@ -879,7 +718,7 @@ "type": "string_match", "details": { "query": "match p = (m:Group)-[:Owns|WriteDacl|GenericAll|WriteOwner|ExecuteDCOM|GenericWrite|AllowedToDelegate|ForceChangePassword]->(n:Computer) where m.objectid ends with '-513' return p", - "complexity": 2 + "complexity": 3 } }, { @@ -887,71 +726,71 @@ "type": "string_match", "details": { "query": "match (dc)-[r:MemberOf*0..]->(g:Group) where g.objectid ends with '-516' with collect(dc) as exclude match p = (c:Computer)-[n:HasSession]->(u:User)-[r2:MemberOf*1..]->(g:Group) where g.objectid ends with '-512' and not (c in exclude) return p", - "complexity": 17 + "complexity": 19 } }, { "name": "Support add expressions", "type": "string_match", "details": { - "query": "match (n) return n.value + n.other_value as combined", - "complexity": 3 + "query": "match (n:Product) return n.value + n.other_value as combined", + "complexity": 1 } }, { "name": "Support subtract expressions", "type": "string_match", "details": { - "query": "match (n) return n.value - n.other_value as combined", - "complexity": 3 + "query": "match (n:Product) return n.value - n.other_value as combined", + "complexity": 1 } }, { "name": "Support multiply expressions", "type": "string_match", "details": { - "query": "match (n) return n.value * n.other_value as combined", - "complexity": 3 + "query": "match (n:Product) return n.value * n.other_value as combined", + "complexity": 1 } }, { "name": "Support divide expressions", "type": "string_match", "details": { - "query": "match (n) return n.value / n.other_value as combined", - "complexity": 3 + "query": "match (n:Product) return n.value / n.other_value as combined", + "complexity": 1 } }, { "name": "Support modulo expressions", "type": "string_match", "details": { - "query": "match (n) return n.value % n.other_value as combined", - "complexity": 3 + "query": "match (n:Product) return n.value % n.other_value as combined", + "complexity": 1 } }, { "name": "Support power of expressions", "type": "string_match", "details": { - "query": "match (n) return n.value ^ n.other_value as combined", - "complexity": 3 + "query": "match (n:Product) return n.value ^ n.other_value as combined", + "complexity": 1 } }, { "name": "Support complex unary expressions", "type": "string_match", "details": { - "query": "match (n) return 1 - 2 / 2 * 100 as combined", - "complexity": 3 + "query": "match (n:Product) return 1 - 2 / 2 * 100 as combined", + "complexity": 1 } }, { "name": "Support complex arithmetic expressions", "type": "string_match", "details": { - "query": "match (n) return 1 + 2 % 3 + n.prop_1 ^ n.prop_2 - 300.124 / 2 * 100 as combined", - "complexity": 3 + "query": "match (n:Product) return 1 + 2 % 3 + n.prop_1 ^ n.prop_2 - 300.124 / 2 * 100 as combined", + "complexity": 1 } }, { @@ -967,7 +806,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((n:User)-[:MemberOf]->(g:Group)) where g.highvalue = true and n.hasspn = true return p", - "complexity": 3 + "complexity": 4 } }, { @@ -975,7 +814,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((n)-[:HasSession|AdminTo|Contains|AZLogicAppContributor*1..]->(m:Computer {unconstraineddelegation: true})) where not (n = m) return p", - "complexity": 9 + "complexity": 10 } }, { @@ -983,7 +822,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((n)-[:HasSession|AdminTo|Contains|AZLogicAppContributor*1..]->(m:Computer {unconstraineddelegation: true})) where not (n = m) return p", - "complexity": 9 + "complexity": 10 } }, { @@ -1007,7 +846,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((n)-[*1..]->(m {highvalue: true})) where m.domain = 'DOMAIN.PAIN' and m <> n return p", - "complexity": 12 + "complexity": 13 } }, { @@ -1023,7 +862,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((g:Group {name: 'DOMAIN USERS@DOMAIN.PAIN'})-[*1..]->(n {highvalue: true})) where g.objectid ends with '-513' and g <> n return p", - "complexity": 10 + "complexity": 11 } }, { @@ -1031,7 +870,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((n)-[:HasSession|AdminTo|Contains|AZLogicAppContributor*1..]->(m:Group {name: 'DOMAIN ADMINS@DOMAIN.PAIN'})) where not (n = m) return p", - "complexity": 9 + "complexity": 10 } }, { @@ -1039,7 +878,7 @@ "type": "string_match", "details": { "query": "match p = shortestPath((n)-[:HasSession|AdminTo|Contains|AZLogicAppContributor*5..1]->(m:Group {name: 'DOMAIN ADMINS@DOMAIN.PAIN'})) where not (n = m) return p", - "complexity": 7 + "complexity": 8 } }, { @@ -1047,7 +886,7 @@ "type": "string_match", "details": { "query": "match (n:Computer) where n.operatingsystem =~ '(?i).*(2000|2003|2008|xp|vista|7|me).*' return n", - "complexity": 2 + "complexity": 3 } }, { @@ -1055,7 +894,7 @@ "type": "string_match", "details": { "query": "match (n:User) where n.hasspn = true return n", - "complexity": 1 + "complexity": 2 } }, { diff --git a/packages/go/cypher/test/test.go b/packages/go/cypher/test/test.go index b0338a691d..feee34721b 100644 --- a/packages/go/cypher/test/test.go +++ b/packages/go/cypher/test/test.go @@ -19,10 +19,10 @@ package test import ( "embed" "encoding/json" - "github.com/specterops/bloodhound/cypher/backend" "regexp" "testing" + "github.com/specterops/bloodhound/cypher/backend" "github.com/specterops/bloodhound/cypher/frontend" "github.com/stretchr/testify/require" ) diff --git a/packages/go/dawgs/graph/graph.go b/packages/go/dawgs/graph/graph.go index cbc1482e10..b54af86575 100644 --- a/packages/go/dawgs/graph/graph.go +++ b/packages/go/dawgs/graph/graph.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "time" + "unsafe" "github.com/specterops/bloodhound/dawgs/util/size" ) @@ -126,6 +127,10 @@ func (s ID) Int64() int64 { return int64(s) } +func (s ID) Sizeof() size.Size { + return size.Size(unsafe.Sizeof(s.Uint32())) +} + // String formats the int64 value of the ID as a string. func (s ID) String() string { return strconv.FormatInt(s.Int64(), 10) diff --git a/packages/go/dawgs/graph/node.go b/packages/go/dawgs/graph/node.go index afdcd23e6f..240d1ee849 100644 --- a/packages/go/dawgs/graph/node.go +++ b/packages/go/dawgs/graph/node.go @@ -19,11 +19,10 @@ package graph import ( "encoding/json" "github.com/RoaringBitmap/roaring" - "math" - "sync" - "github.com/RoaringBitmap/roaring/roaring64" "github.com/specterops/bloodhound/dawgs/util/size" + "math" + "sync" ) const ( @@ -77,7 +76,11 @@ func (s *Node) Merge(other *Node) { } func (s *Node) SizeOf() size.Size { - nodeSize := size.Of(s) + s.Kinds.SizeOf() + nodeSize := size.Of(s) + + s.ID.Sizeof() + + s.Kinds.SizeOf() + + s.AddedKinds.SizeOf() + + s.DeletedKinds.SizeOf() if s.Properties != nil { nodeSize += s.Properties.SizeOf() diff --git a/packages/go/dawgs/graph/node_test.go b/packages/go/dawgs/graph/node_test.go new file mode 100644 index 0000000000..2c4b5f602c --- /dev/null +++ b/packages/go/dawgs/graph/node_test.go @@ -0,0 +1,51 @@ +// Copyright 2024 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 graph_test + +import ( + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/stretchr/testify/require" + "testing" +) + +func Test_NodeSizeOf(t *testing.T) { + node := graph.Node{ID: graph.ID(1)} + oldSize := int64(node.SizeOf()) + + // ensure that reassignment of the Kinds field affects the size + node.Kinds = append(node.Kinds, permissionKind, userKind, groupKind) + newSize := int64(node.SizeOf()) + require.Greater(t, newSize, oldSize) + + // ensure that reassignment of the AddedKinds field affects the size + oldSize = newSize + node.AddedKinds = append(node.AddedKinds, permissionKind) + newSize = int64(node.SizeOf()) + require.Greater(t, newSize, oldSize) + + // ensure that reassignment of the DeletedKinds field affects the size + oldSize = newSize + node.DeletedKinds = append(node.DeletedKinds, userKind) + newSize = int64(node.SizeOf()) + require.Greater(t, newSize, oldSize) + + // ensure that reassignment of the Properties field affects the size + oldSize = newSize + node.Properties = graph.NewProperties() + newSize = int64(node.SizeOf()) + require.Greater(t, newSize, oldSize) +} diff --git a/packages/go/dawgs/graph/path.go b/packages/go/dawgs/graph/path.go index ddc6dc0de2..6cdff923bb 100644 --- a/packages/go/dawgs/graph/path.go +++ b/packages/go/dawgs/graph/path.go @@ -18,6 +18,7 @@ package graph import ( "strings" + "unsafe" "github.com/RoaringBitmap/roaring/roaring64" "github.com/specterops/bloodhound/dawgs/util/size" @@ -264,16 +265,12 @@ type PathSegment struct { size size.Size } -func sizeOfPathSegment(segment *PathSegment) size.Size { - return size.Of(*segment) + segment.Node.SizeOf() + size.Of(segment.Branches)*size.Size(cap(segment.Branches)) -} - func NewRootPathSegment(root *Node) *PathSegment { newSegment := &PathSegment{ Node: root, } - newSegment.size = sizeOfPathSegment(newSegment) + newSegment.computeAndSetSize() return newSegment } @@ -289,6 +286,31 @@ func (s *PathSegment) SizeOf() size.Size { return s.size } +func (s *PathSegment) computeAndSetSize() { + s.size = 0 + + s.size += size.Of(s) + size.Size(unsafe.Sizeof(s.size)) + + if s.Node != nil { + s.size += s.Node.SizeOf() + } + if s.Edge != nil { + s.size += s.Edge.SizeOf() + } + if s.Trunk != nil { + s.size += size.Of(s.Trunk) + } + if s.Branches != nil { + s.size += size.Of(s.Branches) * size.Size(cap(s.Branches)) + } + + // recursively add sizes of all branches + for _, branch := range s.Branches { + branch.computeAndSetSize() + s.size += branch.size + } +} + func (s *PathSegment) IsCycle() bool { if s.Trunk != nil { var ( @@ -394,16 +416,17 @@ func (s *PathSegment) Detach() { } } +// Descend returns a PathSegment with an added edge supplied as input, to the node supplied as input. +// All required updates to slices, pointers, and sizes are included in this operation. func (s *PathSegment) Descend(node *Node, relationship *Relationship) *PathSegment { - var ( - nextSegment = &PathSegment{ - Node: node, - Trunk: s, - Edge: relationship, - } - sizeAdded = sizeOfPathSegment(nextSegment) - oldBranchCapacity = cap(s.Branches) - ) + nextSegment := &PathSegment{ + Node: node, + Trunk: s, + Edge: relationship, + } + nextSegment.computeAndSetSize() + sizeAdded := nextSegment.SizeOf() + oldBranchCapacity := cap(s.Branches) // Track the size of the segment nextSegment.size = sizeAdded diff --git a/packages/go/dawgs/graph/path_internal_test.go b/packages/go/dawgs/graph/path_internal_test.go new file mode 100644 index 0000000000..addebcc3b5 --- /dev/null +++ b/packages/go/dawgs/graph/path_internal_test.go @@ -0,0 +1,82 @@ +// Copyright 2024 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 graph + +import ( + "github.com/stretchr/testify/require" + "sync/atomic" + "testing" +) + +var ( + groupKind = StringKind("group") + domainKind = StringKind("domain") + userKind = StringKind("user") + computerKind = StringKind("computer") + permissionKind = StringKind("permission") + membershipKind = StringKind("member") +) + +func Test_ComputeAndSetSize(t *testing.T) { + var ( + idSequence = int64(0) + + domainNode = NewNode(ID(1), NewProperties(), domainKind) + groupNode = NewNode(ID(2), NewProperties(), groupKind) + userNode = NewNode(ID(3), NewProperties(), userKind) + + domainSegment = NewRootPathSegment(domainNode) + originalSize = int64(domainSegment.SizeOf()) + ) + + // Add a Group segment + edge := NewRelationship(ID(atomic.AddInt64(&idSequence, 1)), groupNode.ID, domainNode.ID, NewProperties(), permissionKind) + groupSegment := &PathSegment{ + Node: groupNode, + Trunk: domainSegment, + Edge: edge, + } + groupSegment.computeAndSetSize() + + // Appending the branch and calling the function should update size + domainSegment.Branches = append(domainSegment.Branches, groupSegment) + domainSegment.computeAndSetSize() + + sizeWithOneBranch := int64(domainSegment.SizeOf()) + require.Greater(t, sizeWithOneBranch, originalSize) + + // Add a User segment + edge = NewRelationship(ID(atomic.AddInt64(&idSequence, 1)), userNode.ID, domainNode.ID, NewProperties(), permissionKind) + userSegment := &PathSegment{ + Node: userNode, + Trunk: domainSegment, + Edge: edge, + } + userSegment.computeAndSetSize() + + domainSegment.Branches = append(domainSegment.Branches, userSegment) + domainSegment.computeAndSetSize() + + // Appending the branch and calling the function should update size + sizeWithTwoBranches := int64(domainSegment.SizeOf()) + require.Greater(t, sizeWithTwoBranches, sizeWithOneBranch) + + // Remove one branch and call the function, to ensure the size reduces accordingly + domainSegment.Branches = []*PathSegment{groupSegment} + domainSegment.computeAndSetSize() + require.Less(t, int64(domainSegment.size), sizeWithTwoBranches) +} diff --git a/packages/go/dawgs/graph/path_test.go b/packages/go/dawgs/graph/path_test.go index a5cb5c27fd..90fb47b522 100644 --- a/packages/go/dawgs/graph/path_test.go +++ b/packages/go/dawgs/graph/path_test.go @@ -1,17 +1,17 @@ // 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 graph_test @@ -19,10 +19,10 @@ package graph_test import ( "testing" - "github.com/stretchr/testify/require" "github.com/specterops/bloodhound/dawgs/graph" "github.com/specterops/bloodhound/dawgs/util/size" "github.com/specterops/bloodhound/dawgs/util/test" + "github.com/stretchr/testify/require" ) var ( @@ -105,8 +105,6 @@ func TestPathSegment_SizeOf(t *testing.T) { treeSize = originalSize ) - require.Equal(t, treeSize, int64(domainSegment.SizeOf())) - // Group segment groupSegment := domainSegment.Descend(groupNode, test.Edge(groupNode, domainNode, permissionKind)) diff --git a/packages/go/dawgs/graph/relationships.go b/packages/go/dawgs/graph/relationships.go index 6033c341b8..60a18dab87 100644 --- a/packages/go/dawgs/graph/relationships.go +++ b/packages/go/dawgs/graph/relationships.go @@ -34,7 +34,11 @@ func (s *Relationship) Merge(other *Relationship) { } func (s *Relationship) SizeOf() size.Size { - relSize := size.Of(s) + size.Of(s.Kind) + relSize := size.Of(s) + + s.ID.Sizeof() + + s.StartID.Sizeof() + + s.EndID.Sizeof() + + Kinds{s.Kind}.SizeOf() if s.Properties != nil { relSize += s.Properties.SizeOf() diff --git a/packages/go/dawgs/graph/relationships_test.go b/packages/go/dawgs/graph/relationships_test.go new file mode 100644 index 0000000000..009004b9e6 --- /dev/null +++ b/packages/go/dawgs/graph/relationships_test.go @@ -0,0 +1,43 @@ +// Copyright 2024 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 graph_test + +import ( + "github.com/specterops/bloodhound/dawgs/graph" + "github.com/stretchr/testify/require" + "testing" + "unsafe" +) + +func Test_RelationshipSizeOf(t *testing.T) { + relationship := graph.Relationship{ID: graph.ID(1)} + initialSize := int64(relationship.SizeOf()) + + // ensure that the initial size accounts for all the struct fields + sizeOfIDs := 3 * int64(unsafe.Sizeof(relationship.StartID)) + sizeOfKind := int64(unsafe.Sizeof(relationship.Kind)) + sizeOfProperties := int64(unsafe.Sizeof(relationship.Properties)) + require.Greater(t, initialSize, sizeOfIDs+sizeOfKind+sizeOfProperties) + + // ID, StartID, EndID and Kind have zero-value sizes that aren't impacted by + // reassignment to a non-zero value. Therefore, skipping testing of those fields. + + // ensure that reassignment of the Properties field affects size + relationship.Properties = graph.NewProperties() + newSize := int64(relationship.SizeOf()) + require.Greater(t, newSize, initialSize) +} diff --git a/packages/go/dawgs/ops/traversal.go b/packages/go/dawgs/ops/traversal.go index 775dd2f716..d3e4b9144f 100644 --- a/packages/go/dawgs/ops/traversal.go +++ b/packages/go/dawgs/ops/traversal.go @@ -142,11 +142,10 @@ type TraversalContext struct { LimitSkipTracker LimitSkipTracker } -func Traversal(tx graph.Transaction, plan TraversalPlan, pathVisitors ...PathVisitor) error { +func Traversal(tx graph.Transaction, plan TraversalPlan, pathVisitor PathVisitor) error { defer log.Measure(log.LevelInfo, "Node %d Traversal", plan.Root.ID)() var ( - pathVisitor PathVisitor requireTraversalOrder = plan.Limit > 0 || plan.Skip > 0 rootSegment = graph.NewRootPathSegment(plan.Root) stack = []*graph.PathSegment{rootSegment} @@ -160,12 +159,6 @@ func Traversal(tx graph.Transaction, plan TraversalPlan, pathVisitors ...PathVis }, } - if pvLen := len(pathVisitors); pvLen > 1 { - return fmt.Errorf("specifying more than 1 path visitor is not supported") - } else if pvLen == 1 { - pathVisitor = pathVisitors[0] - } - for len(stack) > 0 { next := stack[len(stack)-1] stack = stack[:len(stack)-1] diff --git a/packages/go/dawgs/util/size/size.go b/packages/go/dawgs/util/size/size.go index 5ab9d888fb..71a9f143b4 100644 --- a/packages/go/dawgs/util/size/size.go +++ b/packages/go/dawgs/util/size/size.go @@ -1,17 +1,17 @@ // 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 size @@ -25,6 +25,11 @@ type Sizable interface { SizeOf() Size } +// Of returns the size of an object. When the input is a pointer +// to an object, the size of the pointer itself is returned, +// as opposed to the size of the memory referenced. This function +// cannot infer the data type of nil, so nil checks need to be +// performed before calling this function. func Of[T any](raw T) Size { return Size(unsafe.Sizeof(raw)) } diff --git a/packages/go/graphschema/ad/ad.go b/packages/go/graphschema/ad/ad.go index da91282124..1533ef2059 100644 --- a/packages/go/graphschema/ad/ad.go +++ b/packages/go/graphschema/ad/ad.go @@ -673,7 +673,7 @@ func (s Property) Name() string { case HomeDirectory: return "Home Directory" case CertificatePolicy: - return "Certificate Policy" + return "Issuance Policy Extensions" case CertTemplateOID: return "Certificate Template OID" case GroupLinkID: diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx index 82704e24de..badcd5d1d3 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/Abuse.tsx @@ -22,29 +22,28 @@ const Abuse: FC = () => { <> This role can be used to grant yourself or another principal any privilege you want against Automation - Accounts, VMs, Key Vaults, and Resource Groups. For example, you can make yourself an administrator of + Accounts, VMs, Key Vaults, and Resource Groups. For example, you can make yourself an administrator of an Azure Subscription by assigning the Owner role at the Subscription scope. - The simplest way to execute this attack is to use the Azure portal to add a new, abusable role - assignment against the target object for yourself. + The simplest way to execute this attack is to use the Azure portal to add a new, abusable role + assignment against the target object for yourself. - If this role is assigned to a Service Principal, you won't be able to authenticate directly to the - Azure portal. In this case: + If this role is assigned to a Service Principal, you won't be able to authenticate directly to the Azure + portal. In this case: - You'll need to acquire a bearer token for the service principal with AzureRM as the audience. - This can be done using BARK's Get-AzureRMTokenWithClientCredentials cmdlet. - + You'll need to acquire a bearer token for the service principal with AzureRM as the audience. This can + be done using BARK's Get-AzureRMTokenWithClientCredentials cmdlet. - Using that token, you can make a call to the AzureRM API to create a new role assignment on the - target object, such as assigning yourself the Owner role. This can be done using BARK's + Using that token, you can make a call to the AzureRM API to create a new role assignment on the target + object, such as assigning yourself the Owner role. This can be done using BARK's New-AzureRMRoleAssignment cmdlet. diff --git a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/General.tsx b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/General.tsx index 63772940d1..414cc574d0 100644 --- a/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/General.tsx +++ b/packages/javascript/bh-shared-ui/src/components/HelpTexts/AZUserAccessAdministrator/General.tsx @@ -20,7 +20,7 @@ import { Typography } from '@mui/material'; const General: FC = () => { return ( - The User Access Administrator role can manage user access to Azure resources, assign roles in Azure RBAC, + The User Access Administrator role can manage user access to Azure resources, assign roles in Azure RBAC, and assign the Owner role to themselves or others. ); diff --git a/packages/javascript/bh-shared-ui/src/graphSchema.ts b/packages/javascript/bh-shared-ui/src/graphSchema.ts index adb0375ea5..bfbc780450 100644 --- a/packages/javascript/bh-shared-ui/src/graphSchema.ts +++ b/packages/javascript/bh-shared-ui/src/graphSchema.ts @@ -530,7 +530,7 @@ export function ActiveDirectoryKindPropertiesToDisplay(value: ActiveDirectoryKin case ActiveDirectoryKindProperties.HomeDirectory: return 'Home Directory'; case ActiveDirectoryKindProperties.CertificatePolicy: - return 'Certificate Policy'; + return 'Issuance Policy Extensions'; case ActiveDirectoryKindProperties.CertTemplateOID: return 'Certificate Template OID'; case ActiveDirectoryKindProperties.GroupLinkID: diff --git a/tools/docker-compose/api.Dockerfile b/tools/docker-compose/api.Dockerfile index f586d90653..4b9653764e 100644 --- a/tools/docker-compose/api.Dockerfile +++ b/tools/docker-compose/api.Dockerfile @@ -17,8 +17,8 @@ ######## # Global build args ################ -ARG SHARPHOUND_VERSION=v2.3.3 -ARG AZUREHOUND_VERSION=v2.1.8 +ARG SHARPHOUND_VERSION=v2.4.1 +ARG AZUREHOUND_VERSION=v2.1.9 ######## # Package other assets