diff --git a/cmd/api/src/database/migration/migrations/v5.16.0.sql b/cmd/api/src/database/migration/migrations/v5.16.0.sql new file mode 100644 index 0000000000..6ed6f62fa0 --- /dev/null +++ b/cmd/api/src/database/migration/migrations/v5.16.0.sql @@ -0,0 +1,21 @@ +-- 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 + + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX IF NOT EXISTS idx_saved_queries_description ON saved_queries using gin(description gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_saved_queries_name ON saved_queries USING gin(name gin_trgm_ops); diff --git a/cmd/api/src/model/filter.go b/cmd/api/src/model/filter.go index 53d8c61228..0407e70bfb 100644 --- a/cmd/api/src/model/filter.go +++ b/cmd/api/src/model/filter.go @@ -39,6 +39,7 @@ const ( LessThanOrEquals FilterOperator = "lte" Equals FilterOperator = "eq" NotEquals FilterOperator = "neq" + ApproximatelyEquals FilterOperator = "~eq" GreaterThanSymbol string = ">" GreaterThanOrEqualsSymbol string = ">=" @@ -46,6 +47,7 @@ const ( LessThanOrEqualsSymbol string = "<=" EqualsSymbol string = "=" NotEqualsSymbol string = "<>" + ApproximatelyEqualSymbol string = "ILIKE" TrueString = "true" FalseString = "false" @@ -79,6 +81,9 @@ func ParseFilterOperator(raw string) (FilterOperator, error) { case NotEquals: return NotEquals, nil + case ApproximatelyEquals: + return ApproximatelyEquals, nil + default: return "", fmt.Errorf("unknown query parameter filter predicate: %s", raw) } @@ -165,6 +170,9 @@ func (s QueryParameterFilterMap) BuildSQLFilter() (SQLFilter, error) { predicate = EqualsSymbol case NotEquals: predicate = NotEqualsSymbol + case ApproximatelyEquals: + predicate = ApproximatelyEqualSymbol + filter.Value = fmt.Sprintf("%%%s%%", filter.Value) default: return SQLFilter{}, fmt.Errorf("invalid filter predicate specified") } @@ -344,6 +352,6 @@ func (s QueryParameterFilterParser) ParseQueryParameterFilters(request *http.Req func NewQueryParameterFilterParser() QueryParameterFilterParser { return QueryParameterFilterParser{ - re: regexp.MustCompile(`([\w]+):([\w\d\--_]+)`), + re: regexp.MustCompile(`([~\w]+):([\w\--_ ]+)`), } } diff --git a/cmd/api/src/model/filter_internal_test.go b/cmd/api/src/model/filter_internal_test.go index 0a6e755a34..cccb477b29 100644 --- a/cmd/api/src/model/filter_internal_test.go +++ b/cmd/api/src/model/filter_internal_test.go @@ -25,10 +25,36 @@ import ( func TestQueryParameterFilterParser_ParseQueryParameterFilter(t *testing.T) { parser := NewQueryParameterFilterParser() - if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:auth.value"); err != nil { - t.Fatalf("Failed parsing query parameter: %v", err) - } else { - require.Equal(t, filter.Name, "parameter") - require.Equal(t, "auth.value", filter.Value) - } + t.Run("parser should parse a parameter filter", func(t *testing.T) { + if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:auth.value"); err != nil { + t.Fatalf("Failed parsing query parameter: %v", err) + } else { + require.Equal(t, filter.Name, "parameter") + require.Equal(t, "auth.value", filter.Value) + } + }) + + t.Run("parser should parse a parameter with ~", func(t *testing.T) { + if filter, err := parser.ParseQueryParameterFilter("parameter", "~eq:auth.value"); err != nil { + t.Fatalf("Failed parsing query parameter: %v", err) + } else { + require.Equal(t, filter.Name, "parameter") + require.Equal(t, "auth.value", filter.Value) + } + }) + + t.Run("parser should parse a parameter filter with spacing", func(t *testing.T) { + if filter, err := parser.ParseQueryParameterFilter("parameter", "eq:hello world"); err != nil { + t.Fatalf("Failed parsing query parameter: %v", err) + } else { + require.Equal(t, filter.Name, "parameter") + require.Equal(t, "hello world", filter.Value) + } + }) + + t.Run("error when parsing an invalid parameter", func(t *testing.T) { + _, err := parser.ParseQueryParameterFilter("parameter", "eq : hello world") + require.Error(t, err) + }) + } diff --git a/cmd/api/src/model/filter_test.go b/cmd/api/src/model/filter_test.go index 370669007f..ffe55a3fe8 100644 --- a/cmd/api/src/model/filter_test.go +++ b/cmd/api/src/model/filter_test.go @@ -76,19 +76,28 @@ func TestModel_BuildSQLFilter_Success(t *testing.T) { IsStringData: false, } + approximatelyEquals := model.QueryParameterFilter{ + Name: "filtercolumn6", + Operator: model.ApproximatelyEquals, + Value: "testing value", + IsStringData: true, + } + expectedResults := map[string]model.SQLFilter{ - "numericMin": {SQLString: fmt.Sprintf("%s > ?", numericMin.Name), Params: []any{numericMin.Value}}, - "numericMax": {SQLString: fmt.Sprintf("%s < ?", numericMax.Name), Params: []any{numericMax.Value}}, - "stringValue": {SQLString: fmt.Sprintf("%s = ?", stringValue.Name), Params: []any{stringValue.Value}}, - "boolEquals": {SQLString: fmt.Sprintf("%s = ?", boolEquals.Name), Params: []any{boolEquals.Value}}, - "boolNotEquals": {SQLString: fmt.Sprintf("%s <> ?", boolNotEquals.Name), Params: []any{boolNotEquals.Value}}, + "numericMin": {SQLString: fmt.Sprintf("%s > ?", numericMin.Name), Params: []any{numericMin.Value}}, + "numericMax": {SQLString: fmt.Sprintf("%s < ?", numericMax.Name), Params: []any{numericMax.Value}}, + "stringValue": {SQLString: fmt.Sprintf("%s = ?", stringValue.Name), Params: []any{stringValue.Value}}, + "boolEquals": {SQLString: fmt.Sprintf("%s = ?", boolEquals.Name), Params: []any{boolEquals.Value}}, + "boolNotEquals": {SQLString: fmt.Sprintf("%s <> ?", boolNotEquals.Name), Params: []any{boolNotEquals.Value}}, + "stringApproximatelyEquals": {SQLString: fmt.Sprintf("%s ILIKE ?", approximatelyEquals.Name), Params: []any{approximatelyEquals.Value}}, } queryParameterFilterMap := model.QueryParameterFilterMap{ - numericMax.Name: model.QueryParameterFilters{numericMin, numericMax}, - stringValue.Name: model.QueryParameterFilters{stringValue}, - boolEquals.Name: model.QueryParameterFilters{boolEquals}, - boolNotEquals.Name: model.QueryParameterFilters{boolNotEquals}, + numericMax.Name: model.QueryParameterFilters{numericMin, numericMax}, + stringValue.Name: model.QueryParameterFilters{stringValue}, + boolEquals.Name: model.QueryParameterFilters{boolEquals}, + boolNotEquals.Name: model.QueryParameterFilters{boolNotEquals}, + approximatelyEquals.Name: model.QueryParameterFilters{approximatelyEquals}, } result, err := queryParameterFilterMap.BuildSQLFilter() diff --git a/cmd/api/src/model/saved_queries.go b/cmd/api/src/model/saved_queries.go index f16c3219bf..5ea317735e 100644 --- a/cmd/api/src/model/saved_queries.go +++ b/cmd/api/src/model/saved_queries.go @@ -53,9 +53,9 @@ func (s SavedQueries) IsSortable(column string) bool { func (s SavedQueries) ValidFilters() map[string][]FilterOperator { return map[string][]FilterOperator{ "user_id": {Equals, NotEquals}, - "name": {Equals, NotEquals}, + "name": {Equals, NotEquals, ApproximatelyEquals}, "query": {Equals, NotEquals}, - "description": {Equals, NotEquals}, + "description": {Equals, NotEquals, ApproximatelyEquals}, } } diff --git a/cmd/api/src/model/saved_queries_test.go b/cmd/api/src/model/saved_queries_test.go index a045509173..3c16f9be09 100644 --- a/cmd/api/src/model/saved_queries_test.go +++ b/cmd/api/src/model/saved_queries_test.go @@ -17,6 +17,7 @@ package model_test import ( + "github.com/stretchr/testify/assert" "testing" "github.com/specterops/bloodhound/src/model" @@ -38,27 +39,42 @@ func TestSavedQueries_ValidFilters(t *testing.T) { validFilters := savedQueries.ValidFilters() require.Equal(t, 4, len(validFilters)) - for _, column := range []string{"user_id", "name", "query", "description"} { + for _, column := range []string{"user_id", "query"} { operators, ok := validFilters[column] require.True(t, ok) - require.Equal(t, 2, len(operators)) + assert.Equal(t, 2, len(operators)) + } + + for _, column := range []string{"name", "description"} { + operators, ok := validFilters[column] + require.True(t, ok) + assert.Equal(t, 3, len(operators)) } } func TestSavedQueries_GetValidFilterPredicatesAsStrings(t *testing.T) { savedQueries := model.SavedQueries{} - for _, column := range []string{"user_id", "name", "query"} { + for _, column := range []string{"user_id", "query"} { predicates, err := savedQueries.GetValidFilterPredicatesAsStrings(column) require.Nil(t, err) require.Equal(t, 2, len(predicates)) - require.True(t, utils.Contains(predicates, string(model.Equals))) - require.True(t, utils.Contains(predicates, string(model.NotEquals))) + assert.True(t, utils.Contains(predicates, string(model.Equals))) + assert.True(t, utils.Contains(predicates, string(model.NotEquals))) + } + + for _, column := range []string{"name", "description"} { + predicates, err := savedQueries.GetValidFilterPredicatesAsStrings(column) + require.Nil(t, err) + require.Equal(t, 3, len(predicates)) + assert.True(t, utils.Contains(predicates, string(model.Equals))) + assert.True(t, utils.Contains(predicates, string(model.NotEquals))) + assert.True(t, utils.Contains(predicates, string(model.ApproximatelyEquals))) } } func TestSavedQueries_IsString(t *testing.T) { savedQueries := model.SavedQueries{} - for _, column := range []string{"name", "query"} { + for _, column := range []string{"name", "query", "description"} { require.True(t, savedQueries.IsString(column)) } } diff --git a/packages/go/graphschema/ad/ad.go b/packages/go/graphschema/ad/ad.go index abbce97cfa..d4a792849c 100644 --- a/packages/go/graphschema/ad/ad.go +++ b/packages/go/graphschema/ad/ad.go @@ -21,7 +21,6 @@ package ad import ( "errors" - graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/go/graphschema/azure/azure.go b/packages/go/graphschema/azure/azure.go index 787ee392e6..00b20f190f 100644 --- a/packages/go/graphschema/azure/azure.go +++ b/packages/go/graphschema/azure/azure.go @@ -21,7 +21,6 @@ package azure import ( "errors" - graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/go/graphschema/common/common.go b/packages/go/graphschema/common/common.go index 9320bb8d29..6fd161585e 100644 --- a/packages/go/graphschema/common/common.go +++ b/packages/go/graphschema/common/common.go @@ -21,7 +21,6 @@ package common import ( "errors" - graph "github.com/specterops/bloodhound/dawgs/graph" ) diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index 796f439f8b..5bd8310351 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -13929,7 +13929,7 @@ }, "api.params.predicate.filter.string": { "type": "string", - "description": "Filter results by column string value. Valid filter predicates are `eq`, `neq`.\n" + "description": "Filter results by column string value. Valid filter predicates are `eq`, `~eq`, `neq`.\n" }, "api.params.predicate.filter.integer": { "type": "integer", diff --git a/packages/go/openapi/src/schemas/api.params.predicate.filter.string.yaml b/packages/go/openapi/src/schemas/api.params.predicate.filter.string.yaml index dba041651c..2f380991f4 100644 --- a/packages/go/openapi/src/schemas/api.params.predicate.filter.string.yaml +++ b/packages/go/openapi/src/schemas/api.params.predicate.filter.string.yaml @@ -16,4 +16,4 @@ type: string description: | - Filter results by column string value. Valid filter predicates are `eq`, `neq`. + Filter results by column string value. Valid filter predicates are `eq`, `~eq`, `neq`.