Skip to content

Commit 6ad9786

Browse files
authored
SecretsManager: list_secrets() now filters values with special chars correctly (#8529)
1 parent 11addde commit 6ad9786

File tree

2 files changed

+145
-2
lines changed

2 files changed

+145
-2
lines changed

moto/secretsmanager/list_secrets/filters.py

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import re
2-
from typing import TYPE_CHECKING, List
2+
from typing import TYPE_CHECKING, Iterator, List, Union
33

44
if TYPE_CHECKING:
55
from ..models import FakeSecret
@@ -76,6 +76,8 @@ def _match_pattern(
7676
return value.startswith(pattern)
7777
else:
7878
pattern_words = split_words(pattern)
79+
if not pattern_words:
80+
return False
7981
value_words = split_words(value)
8082
if not case_sensitive:
8183
pattern_words = [p.lower() for p in pattern_words]
@@ -90,9 +92,40 @@ def _match_pattern(
9092

9193

9294
def split_words(s: str) -> List[str]:
95+
"""
96+
Secrets are split by special characters first (/, +, _, etc)
97+
Partial results are then split again by UpperCasing
98+
"""
99+
special_chars = ["/", "-", "_", "+", "=", ".", "@"]
100+
101+
if s in special_chars:
102+
# Special case: this does not return any values
103+
return []
104+
105+
for char in special_chars:
106+
if char in s:
107+
others = special_chars.copy()
108+
others.remove(char)
109+
contains_other = any([c in s for c in others])
110+
if contains_other:
111+
# Secret contains two different characters, i.e. my/secret+value
112+
# Values like this will not be split
113+
return [s]
114+
else:
115+
return list(split_by_uppercase(s.split(char)))
116+
return list(split_by_uppercase(s))
117+
118+
119+
def split_by_uppercase(s: Union[str, List[str]]) -> Iterator[str]:
93120
"""
94121
Split a string into words. Words are recognized by upper case letters, i.e.:
95122
test -> [test]
96123
MyTest -> [My, Test]
97124
"""
98-
return [x.strip() for x in re.split(r"([^a-z][a-z]+)", s) if x]
125+
if isinstance(s, str):
126+
for x in re.split(r"([^a-z][a-z]+)", s):
127+
if x:
128+
yield x.strip()
129+
else:
130+
for word in s:
131+
yield from split_by_uppercase(word)

tests/test_secretsmanager/test_list_secrets.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,115 @@ def test_with_all_filter():
214214
conn.delete_secret(SecretId=no_match, ForceDeleteWithoutRecovery=True)
215215

216216

217+
@aws_verified
218+
# Verified, but not marked because it's flaky - AWS can take up to 5 minutes before secrets are listed
219+
def test_with_all_filter_special_characters():
220+
unique_name = str(uuid4())[0:6]
221+
secret_with_slash = f"prod/AppBeta/{unique_name}"
222+
secret_with_under = f"prod_AppBeta_{unique_name}"
223+
secret_with_plus = f"prod+AppBeta+{unique_name}"
224+
secret_with_equal = f"prod=AppBeta={unique_name}"
225+
secret_with_dot = f"prod.AppBeta.{unique_name}"
226+
secret_with_at = f"prod@AppBeta@{unique_name}"
227+
secret_with_dash = f"prod-AppBeta-{unique_name}"
228+
# Note that this secret is never found, because the pattern is unknown
229+
secret_with_dash_and_slash = f"prod-AppBeta/{unique_name}"
230+
full_uppercase = f"uat/COMPANY/{unique_name}"
231+
partial_uppercase = f"uat/COMPANYthings/{unique_name}"
232+
233+
all_special_char_names = [
234+
secret_with_slash,
235+
secret_with_under,
236+
secret_with_plus,
237+
secret_with_equal,
238+
secret_with_dot,
239+
secret_with_at,
240+
secret_with_dash,
241+
]
242+
243+
conn = boto_client()
244+
245+
conn.create_secret(Name=secret_with_slash, SecretString="s")
246+
conn.create_secret(Name=secret_with_under, SecretString="s")
247+
conn.create_secret(Name=secret_with_plus, SecretString="s")
248+
conn.create_secret(Name=secret_with_equal, SecretString="s")
249+
conn.create_secret(Name=secret_with_dot, SecretString="s")
250+
conn.create_secret(Name=secret_with_at, SecretString="s")
251+
conn.create_secret(Name=secret_with_dash, SecretString="s")
252+
conn.create_secret(Name=full_uppercase, SecretString="s")
253+
conn.create_secret(Name=partial_uppercase, SecretString="s")
254+
255+
try:
256+
# Partial Match
257+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["AppBeta"]}])
258+
secret_names = [s["Name"] for s in secrets["SecretList"]]
259+
assert secret_names == all_special_char_names
260+
261+
# Partial Match
262+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["Beta"]}])
263+
secret_names = [s["Name"] for s in secrets["SecretList"]]
264+
assert secret_names == all_special_char_names
265+
266+
secrets = conn.list_secrets(
267+
Filters=[{"Key": "all", "Values": ["AppBeta", "prod"]}]
268+
)["SecretList"]
269+
secret_names = [s["Name"] for s in secrets]
270+
assert secret_names == all_special_char_names
271+
272+
# Search for special character itself
273+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["+"]}])
274+
secret_names = [s["Name"] for s in secrets["SecretList"]]
275+
assert not secret_names
276+
277+
# Search for unique postfix
278+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": [unique_name]}])
279+
secret_names = [s["Name"] for s in secrets["SecretList"]]
280+
assert secret_names == (
281+
all_special_char_names + [full_uppercase, partial_uppercase]
282+
)
283+
284+
# Search for unique postfix
285+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["company"]}])
286+
secret_names = [s["Name"] for s in secrets["SecretList"]]
287+
assert secret_names == [full_uppercase]
288+
289+
# This on it's own is not a word
290+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["things"]}])
291+
secret_names = [s["Name"] for s in secrets["SecretList"]]
292+
assert secret_names == []
293+
294+
# This is valid, because it's split as COMPAN + Ythings
295+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["Ythings"]}])
296+
secret_names = [s["Name"] for s in secrets["SecretList"]]
297+
assert secret_names == [partial_uppercase]
298+
299+
# Note that individual letters from COMPANY are not searchable,
300+
# indicating that AWS splits by terms, rather than each individual upper case
301+
# COMPANYThings --> COMPAN, YThings
302+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["N"]}])
303+
secret_names = [s["Name"] for s in secrets["SecretList"]]
304+
assert secret_names == []
305+
306+
#
307+
secrets = conn.list_secrets(Filters=[{"Key": "all", "Values": ["pany"]}])
308+
secret_names = [s["Name"] for s in secrets["SecretList"]]
309+
assert secret_names == []
310+
311+
finally:
312+
conn.delete_secret(SecretId=secret_with_slash, ForceDeleteWithoutRecovery=True)
313+
conn.delete_secret(SecretId=secret_with_under, ForceDeleteWithoutRecovery=True)
314+
conn.delete_secret(SecretId=secret_with_plus, ForceDeleteWithoutRecovery=True)
315+
conn.delete_secret(SecretId=secret_with_equal, ForceDeleteWithoutRecovery=True)
316+
conn.delete_secret(SecretId=secret_with_dot, ForceDeleteWithoutRecovery=True)
317+
conn.delete_secret(SecretId=secret_with_dash, ForceDeleteWithoutRecovery=True)
318+
conn.delete_secret(
319+
SecretId=secret_with_dash_and_slash, ForceDeleteWithoutRecovery=True
320+
)
321+
conn.delete_secret(SecretId=secret_with_at, ForceDeleteWithoutRecovery=True)
322+
conn.delete_secret(SecretId=full_uppercase, ForceDeleteWithoutRecovery=True)
323+
conn.delete_secret(SecretId=partial_uppercase, ForceDeleteWithoutRecovery=True)
324+
325+
217326
@mock_aws
218327
def test_with_no_filter_key():
219328
conn = boto_client()
@@ -441,6 +550,7 @@ def test_with_include_planned_deleted_secrets():
441550
("MyTestPhrase", ["My", "Test", "Phrase"]),
442551
("myTest", ["my", "Test"]),
443552
("my test", ["my", "test"]),
553+
("my/test", ["my", "test"]),
444554
],
445555
)
446556
def test_word_splitter(input, output):

0 commit comments

Comments
 (0)