Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SecretsManager: list_secrets() now filters values with special chars correctly #8529

Merged
Merged
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
37 changes: 35 additions & 2 deletions moto/secretsmanager/list_secrets/filters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import re
from typing import TYPE_CHECKING, List
from typing import TYPE_CHECKING, Iterator, List, Union

if TYPE_CHECKING:
from ..models import FakeSecret
Expand Down Expand Up @@ -76,6 +76,8 @@
return value.startswith(pattern)
else:
pattern_words = split_words(pattern)
if not pattern_words:
return False
value_words = split_words(value)
if not case_sensitive:
pattern_words = [p.lower() for p in pattern_words]
Expand All @@ -90,9 +92,40 @@


def split_words(s: str) -> List[str]:
"""
Secrets are split by special characters first (/, +, _, etc)
Partial results are then split again by UpperCasing
"""
special_chars = ["/", "-", "_", "+", "=", ".", "@"]

if s in special_chars:
# Special case: this does not return any values
return []

for char in special_chars:
if char in s:
others = special_chars.copy()
others.remove(char)
contains_other = any([c in s for c in others])
if contains_other:
# Secret contains two different characters, i.e. my/secret+value
# Values like this will not be split
return [s]

Check warning on line 113 in moto/secretsmanager/list_secrets/filters.py

View check run for this annotation

Codecov / codecov/patch

moto/secretsmanager/list_secrets/filters.py#L113

Added line #L113 was not covered by tests
else:
return list(split_by_uppercase(s.split(char)))
return list(split_by_uppercase(s))


def split_by_uppercase(s: Union[str, List[str]]) -> Iterator[str]:
"""
Split a string into words. Words are recognized by upper case letters, i.e.:
test -> [test]
MyTest -> [My, Test]
"""
return [x.strip() for x in re.split(r"([^a-z][a-z]+)", s) if x]
if isinstance(s, str):
for x in re.split(r"([^a-z][a-z]+)", s):
if x:
yield x.strip()
else:
for word in s:
yield from split_by_uppercase(word)
110 changes: 110 additions & 0 deletions tests/test_secretsmanager/test_list_secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,115 @@ def test_with_all_filter():
conn.delete_secret(SecretId=no_match, ForceDeleteWithoutRecovery=True)


@aws_verified
# Verified, but not marked because it's flaky - AWS can take up to 5 minutes before secrets are listed
def test_with_all_filter_special_characters():
unique_name = str(uuid4())[0:6]
secret_with_slash = f"prod/AppBeta/{unique_name}"
secret_with_under = f"prod_AppBeta_{unique_name}"
secret_with_plus = f"prod+AppBeta+{unique_name}"
secret_with_equal = f"prod=AppBeta={unique_name}"
secret_with_dot = f"prod.AppBeta.{unique_name}"
secret_with_at = f"prod@AppBeta@{unique_name}"
secret_with_dash = f"prod-AppBeta-{unique_name}"
# Note that this secret is never found, because the pattern is unknown
secret_with_dash_and_slash = f"prod-AppBeta/{unique_name}"
full_uppercase = f"uat/COMPANY/{unique_name}"
partial_uppercase = f"uat/COMPANYthings/{unique_name}"

all_special_char_names = [
secret_with_slash,
secret_with_under,
secret_with_plus,
secret_with_equal,
secret_with_dot,
secret_with_at,
secret_with_dash,
]

conn = boto_client()

conn.create_secret(Name=secret_with_slash, SecretString="s")
conn.create_secret(Name=secret_with_under, SecretString="s")
conn.create_secret(Name=secret_with_plus, SecretString="s")
conn.create_secret(Name=secret_with_equal, SecretString="s")
conn.create_secret(Name=secret_with_dot, SecretString="s")
conn.create_secret(Name=secret_with_at, SecretString="s")
conn.create_secret(Name=secret_with_dash, SecretString="s")
conn.create_secret(Name=full_uppercase, SecretString="s")
conn.create_secret(Name=partial_uppercase, SecretString="s")

try:
# Partial Match
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["AppBeta"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == all_special_char_names

# Partial Match
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["Beta"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == all_special_char_names

secrets = conn.list_secrets(
Filters=[{"Key": "all", "Values": ["AppBeta", "prod"]}]
)["SecretList"]
secret_names = [s["Name"] for s in secrets]
assert secret_names == all_special_char_names

# Search for special character itself
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["+"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert not secret_names

# Search for unique postfix
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": [unique_name]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == (
all_special_char_names + [full_uppercase, partial_uppercase]
)

# Search for unique postfix
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["company"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == [full_uppercase]

# This on it's own is not a word
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["things"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == []

# This is valid, because it's split as COMPAN + Ythings
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["Ythings"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == [partial_uppercase]

# Note that individual letters from COMPANY are not searchable,
# indicating that AWS splits by terms, rather than each individual upper case
# COMPANYThings --> COMPAN, YThings
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["N"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == []

#
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["pany"]}])
secret_names = [s["Name"] for s in secrets["SecretList"]]
assert secret_names == []

finally:
conn.delete_secret(SecretId=secret_with_slash, ForceDeleteWithoutRecovery=True)
conn.delete_secret(SecretId=secret_with_under, ForceDeleteWithoutRecovery=True)
conn.delete_secret(SecretId=secret_with_plus, ForceDeleteWithoutRecovery=True)
conn.delete_secret(SecretId=secret_with_equal, ForceDeleteWithoutRecovery=True)
conn.delete_secret(SecretId=secret_with_dot, ForceDeleteWithoutRecovery=True)
conn.delete_secret(SecretId=secret_with_dash, ForceDeleteWithoutRecovery=True)
conn.delete_secret(
SecretId=secret_with_dash_and_slash, ForceDeleteWithoutRecovery=True
)
conn.delete_secret(SecretId=secret_with_at, ForceDeleteWithoutRecovery=True)
conn.delete_secret(SecretId=full_uppercase, ForceDeleteWithoutRecovery=True)
conn.delete_secret(SecretId=partial_uppercase, ForceDeleteWithoutRecovery=True)


@mock_aws
def test_with_no_filter_key():
conn = boto_client()
Expand Down Expand Up @@ -441,6 +550,7 @@ def test_with_include_planned_deleted_secrets():
("MyTestPhrase", ["My", "Test", "Phrase"]),
("myTest", ["my", "Test"]),
("my test", ["my", "test"]),
("my/test", ["my", "test"]),
],
)
def test_word_splitter(input, output):
Expand Down
Loading