From 3130886591d80ec406b01bb67db1952497751867 Mon Sep 17 00:00:00 2001 From: Mistah J <26472282+mistahj67@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:21:52 -1000 Subject: [PATCH 1/5] chore(alerts): Add webhook uri id --- cmd/api/src/api/constant.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/api/src/api/constant.go b/cmd/api/src/api/constant.go index ee8eaeebd9f..e0584f6dd51 100644 --- a/cmd/api/src/api/constant.go +++ b/cmd/api/src/api/constant.go @@ -53,6 +53,7 @@ const ( // URI path parameters URIPathVariableApplicationConfigurationParameter = "parameter" + URIPathVariableAlertWebhookID = "alert_webhook_id" URIPathVariableAssetGroupID = "asset_group_id" URIPathVariableAssetGroupSelectorID = "asset_group_selector_id" URIPathVariableAssetGroupTagID = "asset_group_tag_id" From 3cc3fc97aa3a39ff798d42ad96f4c80d66de043f Mon Sep 17 00:00:00 2001 From: Mistah J <26472282+mistahj67@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:38:11 -1000 Subject: [PATCH 2/5] refactor(filter): create filter middleware + pkg --- cmd/api/src/api/middleware/filters.go | 76 +++++++++++++++ cmd/api/src/api/router/router.go | 8 ++ cmd/api/src/bhctx/bhctx.go | 2 + packages/go/filters/filter.go | 134 ++++++++++++++++++++++++++ packages/go/filters/parser.go | 126 ++++++++++++++++++++++++ 5 files changed, 346 insertions(+) create mode 100644 cmd/api/src/api/middleware/filters.go create mode 100644 packages/go/filters/filter.go create mode 100644 packages/go/filters/parser.go diff --git a/cmd/api/src/api/middleware/filters.go b/cmd/api/src/api/middleware/filters.go new file mode 100644 index 00000000000..07090f76780 --- /dev/null +++ b/cmd/api/src/api/middleware/filters.go @@ -0,0 +1,76 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package middleware + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" + "github.com/specterops/bloodhound/cmd/api/src/model" + "github.com/specterops/bloodhound/packages/go/filters" +) + +// FilterMiddleware parses query parameter filters from the request, validates them against the supplied +// filters.Filterable definition, and enriches the BloodHound context with the resulting filters.Filters map. +// +// When filterable is nil the middleware performs no parsing and passes the request through unchanged. If +// the filters are malformed, reference a column that cannot be filtered, or use an operator the column +// does not support, the middleware writes a 400 response and halts the chain. +func FilterMiddleware(filterable filters.Filterable) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + if filterable == nil { + next.ServeHTTP(response, request) + return + } + + parser := filters.NewQueryParameterFilterParser(append(model.IgnoreFilters(), model.AllPaginationQueryParameters()...)...) + if parsedFilters, err := parser.ParseAndValidate(request.URL.Query(), filterable); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, filterErrorMessage(err), request), response) + return + } else { + bhCtx := bhctx.Get(request.Context()) + bhCtx.Filters = parsedFilters + } + + next.ServeHTTP(response, request) + }) + } +} + +// filterErrorMessage maps a filter validation failure to the appropriate API error response detail, +// preserving the offending column and operator in the message where available. +func filterErrorMessage(err error) string { + var validationErr *filters.ValidationError + if !errors.As(err, &validationErr) { + return api.ErrorResponseDetailsBadQueryParameterFilters + } + + switch { + case errors.Is(validationErr, filters.ErrColumnNotFilterable): + return fmt.Sprintf("%s: %s", api.ErrorResponseDetailsColumnNotFilterable, validationErr.Column) + case errors.Is(validationErr, filters.ErrOperatorNotSupported): + return fmt.Sprintf("%s: %s %s", api.ErrorResponseDetailsFilterPredicateNotSupported, validationErr.Column, validationErr.Operator) + default: + return api.ErrorResponseDetailsBadQueryParameterFilters + } +} diff --git a/cmd/api/src/api/router/router.go b/cmd/api/src/api/router/router.go index 6bd031550a8..0c801dfd77b 100644 --- a/cmd/api/src/api/router/router.go +++ b/cmd/api/src/api/router/router.go @@ -27,6 +27,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/database" "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" + "github.com/specterops/bloodhound/packages/go/filters" ) // With takes a function returning a mux.MiddlewareFunc type and applies it the to variadic list of routes @@ -113,6 +114,13 @@ func (s *Route) CheckFeatureFlag(ff featureFlag, flagKey string) *Route { return s } +// WithFilters wires the query parameter filter middleware onto the route, validating any filters against +// the supplied filters.Filterable definition and enriching the request context with the parsed filters. +func (s *Route) WithFilters(filterable filters.Filterable) *Route { + s.handler.Use(middleware.FilterMiddleware(filterable)) + return s +} + func NewRouter(cfg config.Configuration, authorizer auth.Authorizer, contentSecurityPolicy string) Router { muxRouter := mux.NewRouter() muxRouter.Use(middleware.EnsureRequestBodyClosed()) diff --git a/cmd/api/src/bhctx/bhctx.go b/cmd/api/src/bhctx/bhctx.go index e7eb4a6c905..e3093823aaf 100644 --- a/cmd/api/src/bhctx/bhctx.go +++ b/cmd/api/src/bhctx/bhctx.go @@ -25,6 +25,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/auth" "github.com/specterops/bloodhound/cmd/api/src/model" + "github.com/specterops/bloodhound/packages/go/filters" ) // Use our own type rather than a primitive to avoid collisions @@ -43,6 +44,7 @@ type Context struct { RequestedURL model.AuditableURL RequestIP string RemoteAddr string + Filters filters.Filters } func (s *Context) ConstructGoContext() context.Context { diff --git a/packages/go/filters/filter.go b/packages/go/filters/filter.go new file mode 100644 index 00000000000..d57db72c067 --- /dev/null +++ b/packages/go/filters/filter.go @@ -0,0 +1,134 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +// Package filters provides a storage-agnostic model for parsing and validating query parameter filters. +// It intentionally carries no knowledge of how filters are ultimately applied (SQL, graph, etc.) so that +// it can be reused across the API without coupling to any particular persistence layer. +package filters + +import ( + "errors" + "fmt" +) + +// FilterOperator identifies the comparison predicate applied to a filtered column. +type FilterOperator string + +const ( + GreaterThan FilterOperator = "gt" + GreaterThanOrEquals FilterOperator = "gte" + LessThan FilterOperator = "lt" + LessThanOrEquals FilterOperator = "lte" + Equals FilterOperator = "eq" + NotEquals FilterOperator = "neq" + ApproximatelyEquals FilterOperator = "~eq" +) + +// Validation sentinels classify why a set of query parameter filters failed validation. Callers should +// classify failures with errors.Is and may extract the offending column/operator via a *ValidationError +// using errors.As. These are intentionally free of any transport- or presentation-specific wording. +var ( + ErrMalformedFilter = errors.New("query parameter filter is malformed") + ErrColumnNotFilterable = errors.New("column cannot be filtered") + ErrOperatorNotSupported = errors.New("filter operator is not supported for column") +) + +// ValidationError describes a filter validation failure. It wraps one of the validation sentinels so it +// can be classified with errors.Is, while also carrying the offending column and operator so callers can +// build their own messaging without parsing strings. +type ValidationError struct { + Err error + Column string + Operator FilterOperator +} + +func (s *ValidationError) Error() string { + switch { + case s.Column != "" && s.Operator != "": + return fmt.Sprintf("%s: %s %s", s.Err, s.Column, s.Operator) + case s.Column != "": + return fmt.Sprintf("%s: %s", s.Err, s.Column) + default: + return s.Err.Error() + } +} + +func (s *ValidationError) Unwrap() error { + return s.Err +} + +// ParseFilterOperator validates a raw operator string and returns the corresponding FilterOperator. +func ParseFilterOperator(raw string) (FilterOperator, error) { + switch FilterOperator(raw) { + case GreaterThan: + return GreaterThan, nil + + case GreaterThanOrEquals: + return GreaterThanOrEquals, nil + + case LessThan: + return LessThan, nil + + case LessThanOrEquals: + return LessThanOrEquals, nil + + case Equals: + return Equals, nil + + case NotEquals: + return NotEquals, nil + + case ApproximatelyEquals: + return ApproximatelyEquals, nil + + default: + return "", fmt.Errorf("%w: unknown predicate %q", ErrMalformedFilter, raw) + } +} + +// FilterSetOperator describes how multiple filters on the same column are combined. +type FilterSetOperator string + +const ( + FilterAnd FilterSetOperator = "AND" + FilterOr FilterSetOperator = "OR" +) + +// Filter is a single validated filter applied to a column. +type Filter struct { + Operator FilterOperator + Value string + SetOperator FilterSetOperator + IsStringData bool +} + +// Filters maps a column name to the set of filters applied to it. +type Filters map[string][]Filter + +// FilterableColumn describes a single column that may be filtered on. It carries the set of operators the +// column supports, IsStringData (whether the column holds string data), and SetOperator (how multiple +// filters applied to the column are combined). An empty SetOperator defaults to FilterAnd. +type FilterableColumn struct { + Operators []FilterOperator + IsStringData bool + SetOperator FilterSetOperator +} + +// Filterable is implemented by types that expose the columns that may be filtered along with each +// column's supported operators and data typing. +type Filterable interface { + ValidFilters() map[string]FilterableColumn +} diff --git a/packages/go/filters/parser.go b/packages/go/filters/parser.go new file mode 100644 index 00000000000..a6a6940dcb0 --- /dev/null +++ b/packages/go/filters/parser.go @@ -0,0 +1,126 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package filters + +import ( + "net/url" + "regexp" + "slices" +) + +// queryParameterFilter is a single filter parsed from a request's query parameters. The column it applies +// to is carried as the key of the queryParameterFilterMap rather than duplicated on the struct. It is an +// internal staging type: ParseAndValidate is the only producer of a validated, enriched Filters value. +type queryParameterFilter struct { + Operator FilterOperator + Value string +} + +// queryParameterFilters is a collection of filters parsed for a single column. +type queryParameterFilters []queryParameterFilter + +// queryParameterFilterMap maps a column name to the filters parsed for it. +type queryParameterFilterMap map[string]queryParameterFilters + +// addFilter appends the given filter to the map under the supplied column name. +func (s queryParameterFilterMap) addFilter(name string, filter queryParameterFilter) { + s[name] = append(s[name], filter) +} + +// QueryParameterFilterParser extracts filters from request query parameters. Parameters whose names are +// listed in ignoredParameters are skipped, allowing callers to exclude application-specific concerns such +// as pagination parameters without coupling this package to them. +type QueryParameterFilterParser struct { + valuePattern *regexp.Regexp + ignoredParameters []string +} + +// NewQueryParameterFilterParser returns a parser that ignores the supplied query parameter names. +func NewQueryParameterFilterParser(ignoredParameters ...string) QueryParameterFilterParser { + return QueryParameterFilterParser{ + valuePattern: regexp.MustCompile(`([~\w]+):([\w\--_ ]+)`), + ignoredParameters: ignoredParameters, + } +} + +// parseQueryParameterFilters parses all eligible query parameters into a filter map. +func (s QueryParameterFilterParser) parseQueryParameterFilters(values url.Values) (queryParameterFilterMap, error) { + filters := make(queryParameterFilterMap) + + for name, columnValues := range values { + if slices.Contains(s.ignoredParameters, name) { + continue + } + + for _, value := range columnValues { + if subgroups := s.valuePattern.FindStringSubmatch(value); len(subgroups) > 0 { + if filterPredicate, err := ParseFilterOperator(subgroups[1]); err != nil { + return nil, err + } else { + filters.addFilter(name, queryParameterFilter{ + Operator: filterPredicate, + Value: subgroups[2], + }) + } + } + } + } + + return filters, nil +} + +// ParseAndValidate parses the supplied query parameters and validates them against the Filterable schema, +// returning a fully-enriched Filters value. Every returned Filter has its IsStringData populated from the +// column definition. On failure it returns a *ValidationError wrapping one of the validation sentinels. +func (s QueryParameterFilterParser) ParseAndValidate(values url.Values, filterable Filterable) (Filters, error) { + var ( + validColumns = filterable.ValidFilters() + parsedFilters = Filters{} + ) + + queryFilters, err := s.parseQueryParameterFilters(values) + if err != nil { + return nil, &ValidationError{Err: err} + } + + for name, columnFilters := range queryFilters { + column, isFilterable := validColumns[name] + if !isFilterable { + return nil, &ValidationError{Err: ErrColumnNotFilterable, Column: name} + } + + setOperator := column.SetOperator + if setOperator == "" { + setOperator = FilterAnd + } + + for _, columnFilter := range columnFilters { + if !slices.Contains(column.Operators, columnFilter.Operator) { + return nil, &ValidationError{Err: ErrOperatorNotSupported, Column: name, Operator: columnFilter.Operator} + } + + parsedFilters[name] = append(parsedFilters[name], Filter{ + Operator: columnFilter.Operator, + Value: columnFilter.Value, + SetOperator: setOperator, + IsStringData: column.IsStringData, + }) + } + } + + return parsedFilters, nil +} From 9af1ca6df07627b214d9d60f140aea928c5f977d Mon Sep 17 00:00:00 2001 From: Mistah J <26472282+mistahj67@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:01:44 -1000 Subject: [PATCH 3/5] chore: simplify --- cmd/api/src/api/middleware/filters.go | 8 +- packages/go/filters/filter.go | 60 ++++++--------- packages/go/filters/parser.go | 101 ++++++++------------------ 3 files changed, 54 insertions(+), 115 deletions(-) diff --git a/cmd/api/src/api/middleware/filters.go b/cmd/api/src/api/middleware/filters.go index 07090f76780..1743d6cfbd1 100644 --- a/cmd/api/src/api/middleware/filters.go +++ b/cmd/api/src/api/middleware/filters.go @@ -58,7 +58,7 @@ func FilterMiddleware(filterable filters.Filterable) mux.MiddlewareFunc { } // filterErrorMessage maps a filter validation failure to the appropriate API error response detail, -// preserving the offending column and operator in the message where available. +// preserving the offending field and operator in the message where available. func filterErrorMessage(err error) string { var validationErr *filters.ValidationError if !errors.As(err, &validationErr) { @@ -66,10 +66,10 @@ func filterErrorMessage(err error) string { } switch { - case errors.Is(validationErr, filters.ErrColumnNotFilterable): - return fmt.Sprintf("%s: %s", api.ErrorResponseDetailsColumnNotFilterable, validationErr.Column) + case errors.Is(validationErr, filters.ErrFieldNotFilterable): + return fmt.Sprintf("%s: %s", api.ErrorResponseDetailsColumnNotFilterable, validationErr.Field) case errors.Is(validationErr, filters.ErrOperatorNotSupported): - return fmt.Sprintf("%s: %s %s", api.ErrorResponseDetailsFilterPredicateNotSupported, validationErr.Column, validationErr.Operator) + return fmt.Sprintf("%s: %s %s", api.ErrorResponseDetailsFilterPredicateNotSupported, validationErr.Field, validationErr.Operator) default: return api.ErrorResponseDetailsBadQueryParameterFilters } diff --git a/packages/go/filters/filter.go b/packages/go/filters/filter.go index d57db72c067..3ac9ff805ee 100644 --- a/packages/go/filters/filter.go +++ b/packages/go/filters/filter.go @@ -38,29 +38,29 @@ const ( ) // Validation sentinels classify why a set of query parameter filters failed validation. Callers should -// classify failures with errors.Is and may extract the offending column/operator via a *ValidationError +// classify failures with errors.Is and may extract the offending field/operator via a *ValidationError // using errors.As. These are intentionally free of any transport- or presentation-specific wording. var ( ErrMalformedFilter = errors.New("query parameter filter is malformed") - ErrColumnNotFilterable = errors.New("column cannot be filtered") + ErrFieldNotFilterable = errors.New("column cannot be filtered") ErrOperatorNotSupported = errors.New("filter operator is not supported for column") ) // ValidationError describes a filter validation failure. It wraps one of the validation sentinels so it -// can be classified with errors.Is, while also carrying the offending column and operator so callers can +// can be classified with errors.Is, while also carrying the offending field and operator so callers can // build their own messaging without parsing strings. type ValidationError struct { Err error - Column string + Field string Operator FilterOperator } func (s *ValidationError) Error() string { switch { - case s.Column != "" && s.Operator != "": - return fmt.Sprintf("%s: %s %s", s.Err, s.Column, s.Operator) - case s.Column != "": - return fmt.Sprintf("%s: %s", s.Err, s.Column) + case s.Field != "" && s.Operator != "": + return fmt.Sprintf("%s: %s %s", s.Err, s.Field, s.Operator) + case s.Field != "": + return fmt.Sprintf("%s: %s", s.Err, s.Field) default: return s.Err.Error() } @@ -72,28 +72,9 @@ func (s *ValidationError) Unwrap() error { // ParseFilterOperator validates a raw operator string and returns the corresponding FilterOperator. func ParseFilterOperator(raw string) (FilterOperator, error) { - switch FilterOperator(raw) { - case GreaterThan: - return GreaterThan, nil - - case GreaterThanOrEquals: - return GreaterThanOrEquals, nil - - case LessThan: - return LessThan, nil - - case LessThanOrEquals: - return LessThanOrEquals, nil - - case Equals: - return Equals, nil - - case NotEquals: - return NotEquals, nil - - case ApproximatelyEquals: - return ApproximatelyEquals, nil - + switch operator := FilterOperator(raw); operator { + case GreaterThan, GreaterThanOrEquals, LessThan, LessThanOrEquals, Equals, NotEquals, ApproximatelyEquals: + return operator, nil default: return "", fmt.Errorf("%w: unknown predicate %q", ErrMalformedFilter, raw) } @@ -107,28 +88,29 @@ const ( FilterOr FilterSetOperator = "OR" ) -// Filter is a single validated filter applied to a column. +// Filter is a single validated filter applied to a field. type Filter struct { + Field string Operator FilterOperator Value string SetOperator FilterSetOperator IsStringData bool } -// Filters maps a column name to the set of filters applied to it. +// Filters maps a field name to the set of filters applied to it. type Filters map[string][]Filter -// FilterableColumn describes a single column that may be filtered on. It carries the set of operators the -// column supports, IsStringData (whether the column holds string data), and SetOperator (how multiple -// filters applied to the column are combined). An empty SetOperator defaults to FilterAnd. -type FilterableColumn struct { +// FilterableField describes a single field that may be filtered on. It carries the set of operators the +// field supports, IsStringData (whether the field holds string data), and SetOperator (how multiple +// filters applied to the field are combined). An empty SetOperator defaults to FilterAnd. +type FilterableField struct { Operators []FilterOperator IsStringData bool SetOperator FilterSetOperator } -// Filterable is implemented by types that expose the columns that may be filtered along with each -// column's supported operators and data typing. +// Filterable is implemented by types that expose the fields that may be filtered along with each +// field's supported operators and data typing. type Filterable interface { - ValidFilters() map[string]FilterableColumn + ValidFilters() map[string]FilterableField } diff --git a/packages/go/filters/parser.go b/packages/go/filters/parser.go index a6a6940dcb0..a87fe1448fe 100644 --- a/packages/go/filters/parser.go +++ b/packages/go/filters/parser.go @@ -22,25 +22,6 @@ import ( "slices" ) -// queryParameterFilter is a single filter parsed from a request's query parameters. The column it applies -// to is carried as the key of the queryParameterFilterMap rather than duplicated on the struct. It is an -// internal staging type: ParseAndValidate is the only producer of a validated, enriched Filters value. -type queryParameterFilter struct { - Operator FilterOperator - Value string -} - -// queryParameterFilters is a collection of filters parsed for a single column. -type queryParameterFilters []queryParameterFilter - -// queryParameterFilterMap maps a column name to the filters parsed for it. -type queryParameterFilterMap map[string]queryParameterFilters - -// addFilter appends the given filter to the map under the supplied column name. -func (s queryParameterFilterMap) addFilter(name string, filter queryParameterFilter) { - s[name] = append(s[name], filter) -} - // QueryParameterFilterParser extracts filters from request query parameters. Parameters whose names are // listed in ignoredParameters are skipped, allowing callers to exclude application-specific concerns such // as pagination parameters without coupling this package to them. @@ -57,68 +38,44 @@ func NewQueryParameterFilterParser(ignoredParameters ...string) QueryParameterFi } } -// parseQueryParameterFilters parses all eligible query parameters into a filter map. -func (s QueryParameterFilterParser) parseQueryParameterFilters(values url.Values) (queryParameterFilterMap, error) { - filters := make(queryParameterFilterMap) - - for name, columnValues := range values { - if slices.Contains(s.ignoredParameters, name) { - continue - } - - for _, value := range columnValues { - if subgroups := s.valuePattern.FindStringSubmatch(value); len(subgroups) > 0 { - if filterPredicate, err := ParseFilterOperator(subgroups[1]); err != nil { - return nil, err - } else { - filters.addFilter(name, queryParameterFilter{ - Operator: filterPredicate, - Value: subgroups[2], - }) - } - } - } - } - - return filters, nil -} - -// ParseAndValidate parses the supplied query parameters and validates them against the Filterable schema, -// returning a fully-enriched Filters value. Every returned Filter has its IsStringData populated from the -// column definition. On failure it returns a *ValidationError wrapping one of the validation sentinels. +// ParseAndValidate parses the supplied query parameters and validates them against the Filterable schema +// in a single pass, returning a fully-enriched Filters value. Every returned Filter has its SetOperator +// and IsStringData populated from the column definition. On failure it returns a *ValidationError wrapping +// one of the validation sentinels. func (s QueryParameterFilterParser) ParseAndValidate(values url.Values, filterable Filterable) (Filters, error) { var ( - validColumns = filterable.ValidFilters() + validFields = filterable.ValidFilters() parsedFilters = Filters{} ) - queryFilters, err := s.parseQueryParameterFilters(values) - if err != nil { - return nil, &ValidationError{Err: err} - } - - for name, columnFilters := range queryFilters { - column, isFilterable := validColumns[name] - if !isFilterable { - return nil, &ValidationError{Err: ErrColumnNotFilterable, Column: name} + for name, fieldValues := range values { + if slices.Contains(s.ignoredParameters, name) { + continue } - setOperator := column.SetOperator - if setOperator == "" { - setOperator = FilterAnd - } + for _, value := range fieldValues { + if subgroups := s.valuePattern.FindStringSubmatch(value); len(subgroups) == 0 { + continue + } else if operator, err := ParseFilterOperator(subgroups[1]); err != nil { + return nil, &ValidationError{Err: err} + } else if field, isFilterable := validFields[name]; !isFilterable { + return nil, &ValidationError{Err: ErrFieldNotFilterable, Field: name} + } else if !slices.Contains(field.Operators, operator) { + return nil, &ValidationError{Err: ErrOperatorNotSupported, Field: name, Operator: operator} + } else { + setOperator := field.SetOperator + if setOperator == "" { + setOperator = FilterAnd + } - for _, columnFilter := range columnFilters { - if !slices.Contains(column.Operators, columnFilter.Operator) { - return nil, &ValidationError{Err: ErrOperatorNotSupported, Column: name, Operator: columnFilter.Operator} + parsedFilters[name] = append(parsedFilters[name], Filter{ + Field: name, + Operator: operator, + Value: subgroups[2], + SetOperator: setOperator, + IsStringData: field.IsStringData, + }) } - - parsedFilters[name] = append(parsedFilters[name], Filter{ - Operator: columnFilter.Operator, - Value: columnFilter.Value, - SetOperator: setOperator, - IsStringData: column.IsStringData, - }) } } From 233c3a0657ceefdd0cc496a9fad8a77c2643eb1c Mon Sep 17 00:00:00 2001 From: Mistah J <26472282+mistahj67@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:28:34 -1000 Subject: [PATCH 4/5] chore: add tests --- packages/go/filters/parser_test.go | 182 +++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 packages/go/filters/parser_test.go diff --git a/packages/go/filters/parser_test.go b/packages/go/filters/parser_test.go new file mode 100644 index 00000000000..0c16874792c --- /dev/null +++ b/packages/go/filters/parser_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package filters_test + +import ( + "net/url" + "testing" + + "github.com/specterops/bloodhound/packages/go/filters" + "github.com/stretchr/testify/require" +) + +// fakeFilterable is a minimal Filterable used to drive ParseAndValidate without coupling the test to any +// persistence layer. +type fakeFilterable struct { + fields map[string]filters.FilterableField +} + +func (s fakeFilterable) ValidFilters() map[string]filters.FilterableField { + return s.fields +} + +func TestParseFilterOperator(t *testing.T) { + for _, raw := range []string{"gt", "gte", "lt", "lte", "eq", "neq", "~eq"} { + t.Run("parses "+raw, func(t *testing.T) { + operator, err := filters.ParseFilterOperator(raw) + require.NoError(t, err) + require.Equal(t, filters.FilterOperator(raw), operator) + }) + } + + t.Run("rejects an unknown predicate and wraps ErrMalformedFilter", func(t *testing.T) { + _, err := filters.ParseFilterOperator("bogus") + require.Error(t, err) + require.ErrorIs(t, err, filters.ErrMalformedFilter) + require.Contains(t, err.Error(), "bogus") + }) +} + +func TestParseAndValidate_Parsing(t *testing.T) { + var ( + parser = filters.NewQueryParameterFilterParser() + filterable = fakeFilterable{fields: map[string]filters.FilterableField{ + "parameter": {Operators: []filters.FilterOperator{filters.Equals, filters.ApproximatelyEquals}}, + }} + ) + + // These cases mirror the legacy model parser tests to vet behavioral parity. + cases := []struct { + name string + value string + expectedOperator filters.FilterOperator + expectedValue string + }{ + {name: "parses a parameter filter", value: "eq:auth.value", expectedOperator: filters.Equals, expectedValue: "auth.value"}, + {name: "parses a parameter with ~", value: "~eq:auth.value", expectedOperator: filters.ApproximatelyEquals, expectedValue: "auth.value"}, + {name: "parses a parameter filter with spacing", value: "eq:hello world", expectedOperator: filters.Equals, expectedValue: "hello world"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + parsed, err := parser.ParseAndValidate(url.Values{"parameter": {testCase.value}}, filterable) + require.NoError(t, err) + require.Len(t, parsed["parameter"], 1) + require.Equal(t, "parameter", parsed["parameter"][0].Field) + require.Equal(t, testCase.expectedOperator, parsed["parameter"][0].Operator) + require.Equal(t, testCase.expectedValue, parsed["parameter"][0].Value) + }) + } + + t.Run("silently skips a non-matching value", func(t *testing.T) { + parsed, err := parser.ParseAndValidate(url.Values{"parameter": {"eq : hello world"}}, filterable) + require.NoError(t, err) + require.Empty(t, parsed["parameter"]) + }) + + t.Run("parses multiple values for the same field in order", func(t *testing.T) { + parsed, err := parser.ParseAndValidate(url.Values{"parameter": {"eq:first", "~eq:second"}}, filterable) + require.NoError(t, err) + require.Len(t, parsed["parameter"], 2) + require.Equal(t, "first", parsed["parameter"][0].Value) + require.Equal(t, filters.ApproximatelyEquals, parsed["parameter"][1].Operator) + require.Equal(t, "second", parsed["parameter"][1].Value) + }) +} + +func TestParseAndValidate_Enrichment(t *testing.T) { + parser := filters.NewQueryParameterFilterParser() + + t.Run("enriches IsStringData and defaults an empty SetOperator to FilterAnd", func(t *testing.T) { + filterable := fakeFilterable{fields: map[string]filters.FilterableField{ + "name": {Operators: []filters.FilterOperator{filters.Equals}, IsStringData: true}, + }} + + parsed, err := parser.ParseAndValidate(url.Values{"name": {"eq:value"}}, filterable) + require.NoError(t, err) + require.Len(t, parsed["name"], 1) + require.True(t, parsed["name"][0].IsStringData) + require.Equal(t, filters.FilterAnd, parsed["name"][0].SetOperator) + }) + + t.Run("carries a declared FilterOr SetOperator through to every filter", func(t *testing.T) { + filterable := fakeFilterable{fields: map[string]filters.FilterableField{ + "name": {Operators: []filters.FilterOperator{filters.Equals}, SetOperator: filters.FilterOr}, + }} + + parsed, err := parser.ParseAndValidate(url.Values{"name": {"eq:a", "eq:b"}}, filterable) + require.NoError(t, err) + require.Len(t, parsed["name"], 2) + require.Equal(t, filters.FilterOr, parsed["name"][0].SetOperator) + require.Equal(t, filters.FilterOr, parsed["name"][1].SetOperator) + }) +} + +func TestParseAndValidate_IgnoredParameters(t *testing.T) { + var ( + parser = filters.NewQueryParameterFilterParser("skip", "limit") + filterable = fakeFilterable{fields: map[string]filters.FilterableField{ + "name": {Operators: []filters.FilterOperator{filters.Equals}}, + }} + ) + + // Ignored parameters are skipped before field validation, so a value that would otherwise fail the + // filterable lookup must not produce an error. + parsed, err := parser.ParseAndValidate(url.Values{ + "skip": {"eq:0"}, + "limit": {"eq:10"}, + "name": {"eq:value"}, + }, filterable) + require.NoError(t, err) + require.Len(t, parsed["name"], 1) + require.NotContains(t, parsed, "skip") + require.NotContains(t, parsed, "limit") +} + +func TestParseAndValidate_Errors(t *testing.T) { + var ( + parser = filters.NewQueryParameterFilterParser() + filterable = fakeFilterable{fields: map[string]filters.FilterableField{ + "name": {Operators: []filters.FilterOperator{filters.Equals}}, + }} + ) + + t.Run("ErrFieldNotFilterable for an unknown field", func(t *testing.T) { + _, err := parser.ParseAndValidate(url.Values{"unknown": {"eq:value"}}, filterable) + require.ErrorIs(t, err, filters.ErrFieldNotFilterable) + + var validationError *filters.ValidationError + require.ErrorAs(t, err, &validationError) + require.Equal(t, "unknown", validationError.Field) + }) + + t.Run("ErrOperatorNotSupported for an unsupported operator", func(t *testing.T) { + _, err := parser.ParseAndValidate(url.Values{"name": {"gt:value"}}, filterable) + require.ErrorIs(t, err, filters.ErrOperatorNotSupported) + + var validationError *filters.ValidationError + require.ErrorAs(t, err, &validationError) + require.Equal(t, "name", validationError.Field) + require.Equal(t, filters.GreaterThan, validationError.Operator) + }) + + t.Run("ErrMalformedFilter for an unknown predicate, preserving the raw predicate", func(t *testing.T) { + _, err := parser.ParseAndValidate(url.Values{"name": {"bogus:value"}}, filterable) + require.ErrorIs(t, err, filters.ErrMalformedFilter) + require.Contains(t, err.Error(), "bogus") + }) +} From 149721123b8223588135e33d3687efa9d43b8711 Mon Sep 17 00:00:00 2001 From: Mistah J <26472282+mistahj67@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:15:44 -1000 Subject: [PATCH 5/5] fix: rabbit feed --- cmd/api/src/api/middleware/filters.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/api/src/api/middleware/filters.go b/cmd/api/src/api/middleware/filters.go index 1743d6cfbd1..a6e429f0d82 100644 --- a/cmd/api/src/api/middleware/filters.go +++ b/cmd/api/src/api/middleware/filters.go @@ -36,14 +36,16 @@ import ( // the filters are malformed, reference a column that cannot be filtered, or use an operator the column // does not support, the middleware writes a 400 response and halts the chain. func FilterMiddleware(filterable filters.Filterable) mux.MiddlewareFunc { + if filterable == nil { + return func(next http.Handler) http.Handler { + return next + } + } + + parser := filters.NewQueryParameterFilterParser(append(model.IgnoreFilters(), model.AllPaginationQueryParameters()...)...) + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { - if filterable == nil { - next.ServeHTTP(response, request) - return - } - - parser := filters.NewQueryParameterFilterParser(append(model.IgnoreFilters(), model.AllPaginationQueryParameters()...)...) if parsedFilters, err := parser.ParseAndValidate(request.URL.Query(), filterable); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, filterErrorMessage(err), request), response) return