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
12 changes: 12 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,18 @@ class SPANDATA:
Example: "dfRtDYWFbkRONycy.Yxwh66Yjlx.cph0gtNBtJ"
"""

AWS_SECRETSMANAGER_SECRET_ARN = "aws.secretsmanager.secret.arn"
"""
The ARN of the Secrets Manager secret the request refers to.
Example: "arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password-AbCdEf"
"""

AWS_SECRETSMANAGER_SECRET_NAME = "aws.secretsmanager.secret.name"
"""
The name of the Secrets Manager secret the request refers to.
Example: "db-password"
"""

CACHE_HIT = "cache.hit"
"""
A boolean indicating whether the requested data was found in the cache.
Expand Down
58 changes: 52 additions & 6 deletions sentry_sdk/integrations/boto3/_services/secretsmanager.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,67 @@
import re
from typing import TYPE_CHECKING

from sentry_sdk.consts import SPANDATA
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes
from sentry_sdk.integrations.boto3._utils import (
_extract_attributes,
_get_aws_arn_attributes,
)
from sentry_sdk.utils import capture_internal_exceptions

if TYPE_CHECKING:
from typing import Any

from sentry_sdk._types import Attributes
from sentry_sdk.integrations.boto3._context import AwsCallContext

# secret-arn ends with name, a hyphen and six random chars (`…:secret:db-password-AbCdEf`).
# https://docs.aws.amazon.com/secretsmanager/latest/userguide/troubleshoot.html#ARN_secretnamehyphen
_ARN_SUFFIX = re.compile(r"-[A-Za-z0-9]{6}$")


def _get_name_from_arn(arn: str) -> str:
# `…:secret:db-backup` => `db`
return _ARN_SUFFIX.sub("", arn.rsplit(":", 1)[1])


class _SecretsManagerExtension(_ServiceExtension):
def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
attributes: "Attributes" = {}
with capture_internal_exceptions():
attributes.update(
_get_aws_arn_attributes(
(ctx.params.get("SecretId"),), ("secretsmanager",)

# only `CreateSecret` takes a `Name`; every other operations acts on
# a secret by name or full/partial arn.
if "Name" in ctx.params:
attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] = ctx.params["Name"]

if "SecretId" in ctx.params:
secret_id = ctx.params["SecretId"]
# `SecretId` isn't validated yet. `get_request_attributes()` runs before
# botocore validates the params; a malformed one mustn't drop the other attributes.
with capture_internal_exceptions():
arn_attributes = _get_aws_arn_attributes(
(secret_id,), ("secretsmanager",)
)
)
attributes.update(arn_attributes)
if arn_attributes:
attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN] = secret_id
attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] = (
_get_name_from_arn(secret_id)
)
else:
attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] = secret_id

return attributes

def get_response_attributes(
self, ctx: "AwsCallContext", response: "Any"
) -> "Attributes":
# the response's full `ARN` and exact `Name` override the request's values,
# which stay when the response has neither, e.g. failed calls.
return _extract_attributes(
response,
(
("ARN", SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN),
("Name", SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME),
),
)
103 changes: 90 additions & 13 deletions tests/integrations/boto3/test_secretsmanager.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,105 @@
import pytest
from botocore.exceptions import ClientError
from botocore.stub import Stubber

from sentry_sdk.consts import OP, SPANDATA
from tests.integrations.boto3.helpers import capture_spans_by_op
from tests.integrations.boto3.helpers import client_factory as client_factory

SECRET_ARN = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:db-password-AbCdEf"

def test_request_attributes(client_factory, capture_items):
client = client_factory("secretsmanager")
secret_arn = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:orders-AbCdEf"

with Stubber(client) as stubber:
stubber.add_response(
"delete_secret",
@pytest.fixture
def secretsmanager_client(client_factory):
return client_factory("secretsmanager")


@pytest.mark.parametrize(
"method,params,response,expected_arn,expected_name",
[
pytest.param(
"get_secret_value",
{"SecretId": "db-password"},
{"ARN": SECRET_ARN, "Name": "db-password", "SecretString": "hunter2"},
SECRET_ARN,
"db-password",
id="name",
),
pytest.param(
"get_secret_value",
{"SecretId": SECRET_ARN},
{"ARN": SECRET_ARN, "Name": "db-password", "SecretString": "hunter2"},
SECRET_ARN,
"db-password",
id="arn",
),
pytest.param(
"create_secret",
{"Name": "db-password", "SecretString": "hunter2"},
{"ARN": SECRET_ARN, "Name": "db-password"},
SECRET_ARN,
"db-password",
id="create",
),
pytest.param(
"tag_resource",
{"SecretId": SECRET_ARN, "Tags": [{"Key": "team", "Value": "billing"}]},
# doesn't return `ARN` or `Name`, so comes from request's `SecretId`.
{},
{"SecretId": secret_arn, "ForceDeleteWithoutRecovery": True},
)
SECRET_ARN,
"db-password",
id="arn-without-response",
),
],
)
def test_secret_attributes(
secretsmanager_client,
capture_items,
method,
params,
response,
expected_arn,
expected_name,
):
with Stubber(secretsmanager_client) as stubber:
stubber.add_response(method, response, params)
spans = capture_spans_by_op(
lambda: client.delete_secret(
**{"SecretId": secret_arn, "ForceDeleteWithoutRecovery": True}
),
capture_items,
lambda: getattr(secretsmanager_client, method)(**params), capture_items
)

(span,) = spans[OP.RPC]
operation = secretsmanager_client.meta.method_to_api_mapping[method]
assert span["name"] == "SecretsManager." + operation
attributes = span["attributes"]
assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN] == expected_arn
assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] == expected_name
# secret value is never recorded.
assert "hunter2" not in str(span)


def test_client_error(secretsmanager_client, capture_items):
params = {"SecretId": SECRET_ARN}

with Stubber(secretsmanager_client) as stubber:
stubber.add_client_error(
"get_secret_value",
service_error_code="ResourceNotFoundException",
http_status_code=400,
expected_params=params,
)

def call():
with pytest.raises(ClientError):
secretsmanager_client.get_secret_value(**params)

spans = capture_spans_by_op(call, capture_items)

(span,) = spans[OP.RPC]
attributes = span["attributes"]
assert span["status"] == "error"
assert attributes[SPANDATA.ERROR_TYPE] == "ResourceNotFoundException"
assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012"
assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == secret_arn
assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == SECRET_ARN
# both from the request's `SecretId`.
assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_ARN] == SECRET_ARN
assert attributes[SPANDATA.AWS_SECRETSMANAGER_SECRET_NAME] == "db-password"
Loading