Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from typing import Any

from rest_framework.request import Request
from rest_framework.response import Response

Expand All @@ -11,12 +13,17 @@
ControlSiloOrganizationEndpoint,
OrganizationPermission,
)
from sentry.identity.datadog.provider import DATADOG_VALID_SITES
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.users.models.identity import OrganizationIdentity

MONITORING_PROVIDERS: dict[str, dict[str, str]] = {
MONITORING_PROVIDERS: dict[str, dict[str, Any]] = {
"datadog": {"name": "Datadog"},
"datadog_pat": {"name": "Datadog (Personal Access Token)"},
"datadog_pat": {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Lmk what y'all think about this structure for storing provider-specific configuration for Slack flows. Alternatively we could just add some branching in the Slack message builder to handle providers separately (for the blocks that are different)

"name": "Datadog (Personal Access Token)",
"pat_hint": "Create one at Organization Settings → Access → API Keys in Datadog.",

@shashjar shashjar Jun 26, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We'll eventually want more instruction/detail than this (and probably displayed in a more readable/understandable way). e.g. we'll want to indicate which scopes/expiration to set for the token (all read scopes, max of 1 year). I'm thinking this is okay for now for testing.

"sites": DATADOG_VALID_SITES,
},
"gcp": {"name": "Google Cloud Platform"},
}

Expand Down
4 changes: 4 additions & 0 deletions src/sentry/integrations/slack/message_builder/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class SlackAction(StrEnum):
SEER_AUTOFIX_VIEW_IN_SENTRY = "seer_autofix_view_in_sentry"
SEER_AUTOFIX_VIEW_PR = "seer_autofix_view_pr"

CONNECT_MONITORING_PROVIDER = "connect_monitoring_provider"
SKIP_MONITORING_PROVIDER = "skip_monitoring_provider"
DECLINE_MONITORING_PROVIDER = "decline_monitoring_provider"


INCIDENT_COLOR_MAPPING = {
"Resolved": "_incident_resolved",
Expand Down
55 changes: 55 additions & 0 deletions src/sentry/integrations/slack/webhooks/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from sentry.api.helpers.group_index import update_groups
from sentry.auth.access import from_member
from sentry.exceptions import UnableToAcceptMemberInvitationException
from sentry.identity.services.identity import identity_service
from sentry.integrations.messaging.metrics import (
MessageInteractionFailureReason,
MessagingInteractionEvent,
Expand All @@ -46,6 +47,11 @@
from sentry.integrations.slack.sdk_client import SlackSdkClient
from sentry.integrations.slack.spec import SlackMessagingSpec
from sentry.integrations.slack.utils.errors import MODAL_NOT_FOUND, unpack_slack_api_error
from sentry.integrations.slack.webhooks.monitoring_provider import (
MONITORING_PROVIDER_CALLBACK_ID,
handle_monitoring_provider_submission,
open_monitoring_provider_modal,
)
from sentry.integrations.types import ExternalProviderEnum, IntegrationProviderSlug
from sentry.integrations.utils.scope import bind_org_context_from_integration
from sentry.issues.action_log import ActionSource, GroupActionActor, action_context_scope
Expand Down Expand Up @@ -753,9 +759,27 @@
if action_option in ["approve_member", "reject_member"]:
return self.handle_member_approval(slack_request, action_option)

if action_option in NOTIFICATION_SETTINGS_ACTION_OPTIONS:

Check warning on line 762 in src/sentry/integrations/slack/webhooks/action.py

View check run for this annotation

@sentry/warden / warden: security-review

User-submitted PAT logged to application logs via slack.action.request

The monitoring-provider modal submits a third-party personal access token in `view.state.values.token_block.token_input.value`, which is part of `slack_request.data`. The `post()` handler logs the entire `slack_request.data` at INFO level (`slack.action.request`) before dispatching the new view_submission handler, so every PAT submission is written to application logs in cleartext. Redact the token field before logging.

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.

User-submitted PAT logged to application logs via slack.action.request

The monitoring-provider modal submits a third-party personal access token in view.state.values.token_block.token_input.value, which is part of slack_request.data. The post() handler logs the entire slack_request.data at INFO level (slack.action.request) before dispatching the new view_submission handler, so every PAT submission is written to application logs in cleartext. Redact the token field before logging.

Evidence
  • post() in action.py logs extra={... 'request_data': slack_request.data} at INFO unconditionally (line ~719) before any action dispatch.
  • The new view_submission intercept for MONITORING_PROVIDER_CALLBACK_ID runs after this log, so the payload is already logged.
  • monitoring_provider.py:246 reads the PAT from state_values.token_block.token_input.value, the same value present in the logged slack_request.data.
  • No redaction or field exclusion is applied to request_data, exposing users' third-party tokens to anyone with log access.

Identified by Warden security-review · L2P-KS2

return self.handle_enable_notifications(slack_request)

# Monitoring provider modals have no group context, so intercept before _handle_group_actions.
if slack_request.type == "view_submission":
view = slack_request.data.get("view", {})
if view.get("callback_id") == MONITORING_PROVIDER_CALLBACK_ID:
result = handle_monitoring_provider_submission(slack_request)
if result:
return self.respond(result)
return self.respond()

if action_id == SlackAction.CONNECT_MONITORING_PROVIDER.value:
return self._handle_connect_monitoring_provider(slack_request)

if action_id in {
SlackAction.SKIP_MONITORING_PROVIDER.value,
SlackAction.DECLINE_MONITORING_PROVIDER.value,
}:
return self._handle_skip_monitoring_provider(slack_request, action_id)

action_list = self.get_action_list(slack_request=slack_request)
return self._handle_group_actions(slack_request, request, action_list)

Expand Down Expand Up @@ -899,6 +923,37 @@
)
return self.respond()

def _handle_connect_monitoring_provider(self, slack_request: SlackActionRequest) -> Response:
action_option, _ = self.get_action_option(slack_request=slack_request)
provider_key = action_option or ""
error = open_monitoring_provider_modal(
slack_request,
provider_key,
channel_id=slack_request.channel_id,
)
if error:
return self.respond_ephemeral(error)
return self.respond()

def _handle_skip_monitoring_provider(
self, slack_request: SlackActionRequest, action_id: str | None
) -> Response:
# SKIP_MONITORING_PROVIDER: per-run suppression is handled by the caller that sends
# the prompt (the Seer operator won't re-prompt for the same run), so an ack is enough.
# DECLINE_MONITORING_PROVIDER: persists the provider on the Slack Identity so future
# unsolicited prompts are suppressed across runs.
if action_id == SlackAction.DECLINE_MONITORING_PROVIDER.value:
action_option, _ = self.get_action_option(slack_request=slack_request)
provider_key = action_option or ""
slack_identity_rpc = slack_request.get_identity()
if slack_identity_rpc and provider_key:
data = dict(slack_identity_rpc.data) if slack_identity_rpc.data else {}
declined = set(data.get("declined_monitoring_providers", []))
declined.add(provider_key)
data["declined_monitoring_providers"] = sorted(declined)
identity_service.update_data(identity_id=slack_identity_rpc.id, data=data)
return self.respond()


class _ModalDialog(ABC):
@property
Expand Down
Loading
Loading