Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions src/sentry/search/eap/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
from sentry.search.eap.rpc_utils import and_trace_item_filters
from sentry.search.eap.sampling import validate_sampling
from sentry.search.eap.spans.attributes import SPANS_INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS
from sentry.search.eap.types import EAPResponse, SearchResolverConfig
from sentry.search.eap.types import EAPResponse, SearchResolverConfig, SupportedTraceItemType
from sentry.search.events import constants as qb_constants
from sentry.search.events import fields
from sentry.search.events import filter as event_filter
Expand All @@ -83,6 +83,10 @@ def collect_issue_short_ids_from_parsed_terms(terms: Sequence[object]) -> set[st
return out


class _HiddenApiAttribute(InvalidSearchQuery):
pass


@dataclass(frozen=True)
class SearchResolver:
"""The only attributes are things we want to cache and params
Expand Down Expand Up @@ -531,12 +535,14 @@ def _resolve_term(
self, term: event_search.SearchFilter
) -> tuple[TraceItemFilter, VirtualColumnDefinition | None]:
resolved_column, context_definition = self.resolve_column(term.key.name)
self._raise_if_hidden_resolved_attribute(term.key.name, resolved_column)

value = term.value.value
if self.params.is_timeseries_request and context_definition is not None:
resolved_column, value = self.map_search_term_context_to_original_column(
term, context_definition
)
self._raise_if_hidden_api_attribute(term.key.name, resolved_column)
context_definition = None

if not isinstance(resolved_column.proto_definition, AttributeKey):
Expand Down Expand Up @@ -812,6 +818,7 @@ def resolve_aggregate_term(
self, term: event_search.AggregateFilter
) -> tuple[AggregationFilter, VirtualColumnDefinition | None]:
resolved_column, context = self.resolve_column(term.key.name)
self._raise_if_hidden_resolved_attribute(term.key.name, resolved_column)
proto_definition = resolved_column.proto_definition

if not isinstance(
Expand Down Expand Up @@ -967,7 +974,14 @@ def resolve_columns(
for column in stripped_columns:
match = fields.is_function(column)
has_aggregates = has_aggregates or match is not None
resolved_column, context = self.resolve_column(column, match)
Comment thread
nsdeschenes marked this conversation as resolved.
try:
resolved_column, context = self.resolve_column(column, match)
except _HiddenApiAttribute:
continue
if isinstance(resolved_column, ResolvedAttribute) and self._should_hide_api_attribute(
column, resolved_column
):
continue
Comment thread
cursor[bot] marked this conversation as resolved.
if (
self.config.disable_array_attributes
and isinstance(resolved_column, ResolvedAttribute)
Expand Down Expand Up @@ -1024,12 +1038,52 @@ def resolve_attributes(
resolved_contexts = []
for column in columns:
col, context = self.resolve_attribute(column)
self._raise_if_hidden_api_attribute(column, col)
if self.config.disable_array_attributes and col.internal_type == constants.ARRAY:
continue
resolved_columns.append(col)
resolved_contexts.append(context)
return resolved_columns, resolved_contexts

def should_hide_api_column(
self, column: str, resolved_column: ResolvedAttribute | ResolvedFunction
) -> bool:
if not isinstance(resolved_column, ResolvedAttribute):
return False
return self._should_hide_api_attribute(column, resolved_column)

def _should_hide_api_attribute(
self, column: str, resolved_attribute: ResolvedAttribute
) -> bool:
if self.config.api_attribute_visibility_item_type is None:
return False

from sentry.search.eap.utils import can_expose_attribute_to_api

item_type = SupportedTraceItemType(self.config.api_attribute_visibility_item_type)
visibility_attribute = (
column
if column in self.definitions.contexts or column in self.definitions.columns
else resolved_attribute.internal_name
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Virtual context skips storage visibility

Medium Severity

API attribute visibility checks in _should_hide_api_attribute use the virtual context key, not the underlying storage attribute after remapping. This allows attributes intended to be hidden to be exposed in queries, particularly for timeseries, group-bys, and order-bys, as the check passes on the virtual key while the query uses the unhidden storage key.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 661211e. Configure here.

return not can_expose_attribute_to_api(
visibility_attribute,
item_type,
include_internal=self.config.api_attribute_visibility_include_internal,
)

def _raise_if_hidden_api_attribute(
self, column: str, resolved_attribute: ResolvedAttribute
) -> None:
if self._should_hide_api_attribute(column, resolved_attribute):
raise _HiddenApiAttribute(f"The field {column} is not allowed for this query")

def _raise_if_hidden_resolved_attribute(
self, column: str, resolved_column: ResolvedAttribute | ResolvedFunction
) -> None:
if isinstance(resolved_column, ResolvedAttribute):
self._raise_if_hidden_api_attribute(column, resolved_column)

def resolve_attribute(
self, column: str, public_alias_override: str | None = None
) -> tuple[ResolvedAttribute, VirtualColumnDefinition | None]:
Expand Down Expand Up @@ -1126,7 +1180,10 @@ def resolve_functions(
"""Helper function to resolve a list of functions instead of 1 attribute at a time"""
resolved_functions, resolved_contexts = [], []
for column in columns:
function, context = self.resolve_function(column)
try:
function, context = self.resolve_function(column)
except _HiddenApiAttribute:
continue
resolved_functions.append(function)
resolved_contexts.append(context)
return resolved_functions, resolved_contexts
Expand Down Expand Up @@ -1182,6 +1239,9 @@ def resolve_function(
parsed_args.append(argument_definition.default_arg)
else:
parsed_argument, _ = self.resolve_attribute(argument_definition.default_arg)
self._raise_if_hidden_api_attribute(
argument_definition.default_arg, parsed_argument
)
parsed_args.append(parsed_argument)
missing_args -= 1
continue
Expand All @@ -1196,6 +1256,7 @@ def resolve_function(
)
if isinstance(argument_definition, AttributeArgumentDefinition):
parsed_argument, _ = self.resolve_attribute(argument)
self._raise_if_hidden_api_attribute(argument, parsed_argument)
parsed_args.append(parsed_argument)
else:
if argument_definition.argument_types is None:
Expand Down Expand Up @@ -1282,7 +1343,10 @@ def resolve_equations(
formulas = []
contexts = []
for equation in equations:
formula, context = self.resolve_equation(equation)
try:
formula, context = self.resolve_equation(equation)
except _HiddenApiAttribute:
continue
formulas.append(formula)
contexts.extend(context)
return formulas, contexts
Expand All @@ -1303,6 +1367,8 @@ def resolve_equation(
col, context = self.resolve_column(
operation, public_alias_override=f"equation|{equation}"
)
if isinstance(col, ResolvedAttribute):
self._raise_if_hidden_api_attribute(operation, col)
return col, [context] if context else []
elif isinstance(operation, float):
return (
Expand Down Expand Up @@ -1378,6 +1444,8 @@ def _resolve_operation(
# Resolve the column, and turn it into a RPC Column so it can be used in a BinaryFormula
# Columns in equations must pass default_value=0 otherwise they may become a null and ruin the entire formula
col, context = self.resolve_column(operation, default_value=0)
if isinstance(col, ResolvedAttribute):
self._raise_if_hidden_api_attribute(operation, col)
contexts = [context] if context is not None else []
proto_definition = col.proto_definition

Expand Down
4 changes: 4 additions & 0 deletions src/sentry/search/eap/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class SearchResolverConfig:
# When True, ResolvedAttributes whose internal_type is ARRAY are silently dropped based on
# feature flag organizations:trace-item-details-array-fields
disable_array_attributes: bool = True
# API-only visibility enforcement. Non-API callers should leave this as None
# so backend resolution semantics remain unchanged.
api_attribute_visibility_item_type: str | None = None
api_attribute_visibility_include_internal: bool = False

def extra_conditions(
self,
Expand Down
79 changes: 79 additions & 0 deletions src/sentry/search/eap/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Literal

from google.protobuf.timestamp_pb2 import Timestamp
from sentry_conventions.attributes import ATTRIBUTE_METADATA as ATTRIBUTE_METADATA
from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest

from sentry.search.eap.columns import ColumnDefinitions, ResolvedAttribute
Expand Down Expand Up @@ -237,6 +238,84 @@ def can_expose_attribute(
return True


def _has_internal_convention_visibility(attribute: str) -> bool:
metadata = ATTRIBUTE_METADATA.get(attribute)
if metadata is None:
return False

visibility = metadata.visibility
return getattr(visibility, "value", visibility) == "internal"


def _get_sentry_convention_visibility_candidates(
attribute: str, item_type: SupportedTraceItemType
) -> set[str]:
candidates = {attribute}

if item_type == SupportedTraceItemType.SPANS and attribute.startswith(("dsc.", "_internal.")):
candidates.add(f"sentry.{attribute}")

resolved_attribute = PUBLIC_ALIAS_TO_INTERNAL_MAPPING.get(item_type, {}).get(attribute)
if resolved_attribute is not None:
candidates.add(resolved_attribute.public_alias)
candidates.add(resolved_attribute.internal_name)
if resolved_attribute.replacement:
candidates.add(resolved_attribute.replacement)

for mapping in INTERNAL_TO_PUBLIC_ALIAS_MAPPINGS.get(item_type, {}).values():
public_alias = mapping.get(attribute)
if public_alias is not None:
candidates.add(public_alias)

replacement_map = SENTRY_CONVENTIONS_REPLACEMENT_MAPPINGS.get(item_type, {})
pending = list(candidates)
while pending:
candidate = pending.pop()
replacement = replacement_map.get(candidate)
if replacement is not None and replacement not in candidates:
candidates.add(replacement)
pending.append(replacement)

return candidates
Comment thread
nsdeschenes marked this conversation as resolved.
Comment thread
nsdeschenes marked this conversation as resolved.


def is_internal_sentry_convention_attribute(
attribute: str, item_type: SupportedTraceItemType
) -> bool:
return any(
_has_internal_convention_visibility(candidate)
for candidate in _get_sentry_convention_visibility_candidates(attribute, item_type)
)


def can_expose_attribute_to_api(
attribute: str, item_type: SupportedTraceItemType, include_internal: bool = False
) -> bool:
"""Return whether an attribute may be exposed by public API surfaces.

The visibility check expands the requested attribute to its related public
aliases, internal names, and replacement attributes because any of those may
carry the metadata that marks the underlying convention as internal.
`include_internal` only allows those Sentry-owned internal convention
attributes. It does not bypass `can_expose_attribute`, which still filters
private attributes first.
"""
candidates = _get_sentry_convention_visibility_candidates(attribute, item_type)

for candidate in candidates:
Comment thread
nsdeschenes marked this conversation as resolved.
if not can_expose_attribute(candidate, item_type, include_internal=include_internal):
return False

# Private attributes are rejected above before this internal-only override
# is applied.
if include_internal:
return True

return not any(
is_internal_sentry_convention_attribute(candidate, item_type) for candidate in candidates
)


def is_sentry_convention_replacement_attribute(
public_alias: str, item_type: SupportedTraceItemType
) -> bool:
Expand Down
4 changes: 4 additions & 0 deletions src/sentry/snuba/rpc_dataset_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ def get_table_rpc_request(cls, query: TableQuery) -> TableRequest:
raise InvalidSearchQuery("orderby must also be in the selected columns or groupby")
else:
resolved_column = resolver.resolve_column(stripped_orderby)[0]
if resolver.should_hide_api_column(stripped_orderby, resolved_column):
raise InvalidSearchQuery(
"orderby must also be in the selected columns or groupby"
)

# Virtual context columns transform values (e.g. "1" -> "low") which
# can produce an undesirable alphabetical sort order. When a sort_column
Expand Down
Loading
Loading