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