Skip to content

Commit 3bd47e2

Browse files
fix(data-masking): fail closed on masking errors (#8446)
1 parent 5d4f08f commit 3bd47e2

4 files changed

Lines changed: 62 additions & 48 deletions

File tree

aws_lambda_powertools/utilities/data_masking/base.py

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616
from jsonpath_ng.ext import parse
1717

1818
from aws_lambda_powertools.utilities.data_masking.exceptions import (
19+
DataMaskingError,
1920
DataMaskingFieldNotFoundError,
2021
DataMaskingUnsupportedTypeError,
2122
)
2223
from aws_lambda_powertools.utilities.data_masking.provider import BaseProvider
23-
from aws_lambda_powertools.warnings import PowertoolsUserWarning
2424

2525
if TYPE_CHECKING:
2626
from collections.abc import Callable, Mapping, Sequence
@@ -395,10 +395,10 @@ def _apply_action_to_fields(
395395
if not result_parse:
396396
if self.raise_on_missing_field:
397397
# If the data for the field is not found, raise an exception.
398-
raise DataMaskingFieldNotFoundError(f"Field or expression {field_parse} not found in {data_parsed}")
398+
raise DataMaskingFieldNotFoundError(f"Field or expression {field_parse} not found")
399399
else:
400400
# If the data for the field is not found, warning.
401-
warnings.warn(f"Field or expression {field_parse} not found in {data_parsed}", stacklevel=2)
401+
warnings.warn(f"Field or expression {field_parse} not found", stacklevel=2)
402402

403403
# For in-place updates, json_parse accepts a callback function
404404
# that receives 3 args: field_value, fields, field_name
@@ -427,31 +427,30 @@ def _apply_masking_rules(self, data: dict, masking_rules: dict) -> dict:
427427
for path, rule in masking_rules.items():
428428
try:
429429
jsonpath_expr = parse(f"$.{path}")
430-
matches = jsonpath_expr.find(result)
430+
except Exception as exc:
431+
raise DataMaskingError(f"Invalid masking path: {path}") from exc
431432

432-
if not matches:
433-
warnings.warn(f"No matches found for path: {path}", stacklevel=2)
434-
continue
433+
matches = jsonpath_expr.find(result)
434+
if not matches:
435+
if self.raise_on_missing_field:
436+
raise DataMaskingFieldNotFoundError(f"Field or expression {path} not found")
435437

436-
for match in matches:
437-
try:
438-
value = match.value
439-
if value is not None:
440-
masked_value = self.provider.erase(str(value), **rule)
441-
match.full_path.update(result, masked_value)
442-
443-
except Exception as e:
444-
warnings.warn(
445-
f"Error masking value for path {path}: {str(e)}",
446-
category=PowertoolsUserWarning,
447-
stacklevel=2,
448-
)
449-
continue
450-
451-
except Exception as e:
452-
warnings.warn(f"Error processing path {path}: {str(e)}", category=PowertoolsUserWarning, stacklevel=2)
438+
warnings.warn(f"No matches found for path: {path}", stacklevel=2)
453439
continue
454440

441+
for match in matches:
442+
value = match.value
443+
if value is None:
444+
continue
445+
446+
try:
447+
masked_value = self.provider.erase(str(value), **rule)
448+
match.full_path.update(result, masked_value)
449+
except DataMaskingError:
450+
raise
451+
except Exception as exc:
452+
raise DataMaskingError(f"Failed to mask field at path: {path}") from exc
453+
455454
return result
456455

457456
def _mask_nested_field(self, data: dict, field_path: str, mask_function):
Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,40 @@
1-
class DataMaskingUnsupportedTypeError(Exception):
1+
class DataMaskingError(Exception):
2+
"""
3+
Base exception for data masking failures.
4+
"""
5+
6+
7+
class DataMaskingUnsupportedTypeError(DataMaskingError):
28
"""
39
UnsupportedType Error
410
"""
511

612

7-
class DataMaskingDecryptKeyError(Exception):
13+
class DataMaskingDecryptKeyError(DataMaskingError):
814
"""
915
Decrypting with an invalid AWS KMS Key ARN.
1016
"""
1117

1218

13-
class DataMaskingEncryptKeyError(Exception):
19+
class DataMaskingEncryptKeyError(DataMaskingError):
1420
"""
1521
Encrypting with an invalid AWS KMS Key ARN.
1622
"""
1723

1824

19-
class DataMaskingDecryptValueError(Exception):
25+
class DataMaskingDecryptValueError(DataMaskingError):
2026
"""
2127
Decrypting an invalid field.
2228
"""
2329

2430

25-
class DataMaskingContextMismatchError(Exception):
31+
class DataMaskingContextMismatchError(DataMaskingError):
2632
"""
2733
Decrypting with the incorrect encryption context.
2834
"""
2935

3036

31-
class DataMaskingFieldNotFoundError(Exception):
37+
class DataMaskingFieldNotFoundError(DataMaskingError):
3238
"""
3339
Field not found.
3440
"""

aws_lambda_powertools/utilities/data_masking/provider/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TYPE_CHECKING, Any
77

88
from aws_lambda_powertools.utilities.data_masking.constants import DATA_MASKING_STRING
9+
from aws_lambda_powertools.utilities.data_masking.exceptions import DataMaskingError
910

1011
if TYPE_CHECKING:
1112
from collections.abc import Callable
@@ -183,8 +184,8 @@ def _regex_mask(self, data: str, regex_pattern: str, mask_format: str) -> str:
183184
if regex_pattern not in _regex_cache:
184185
_regex_cache[regex_pattern] = re.compile(regex_pattern)
185186
return _regex_cache[regex_pattern].sub(mask_format, data)
186-
except re.error:
187-
return data
187+
except re.error as exc:
188+
raise DataMaskingError("Invalid regex pattern") from exc
188189

189190
def _custom_erase(self, data: str) -> str:
190191
if not data:

tests/functional/data_masking/required_dependencies/test_erase_data_masking.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
from aws_lambda_powertools.utilities.data_masking.base import DataMasking
88
from aws_lambda_powertools.utilities.data_masking.constants import DATA_MASKING_STRING
99
from aws_lambda_powertools.utilities.data_masking.exceptions import (
10+
DataMaskingError,
1011
DataMaskingFieldNotFoundError,
1112
DataMaskingUnsupportedTypeError,
1213
)
13-
from aws_lambda_powertools.warnings import PowertoolsUserWarning
1414

1515

1616
@pytest.fixture
@@ -315,6 +315,17 @@ def test_no_matches_for_masking_rule(data_masker):
315315
data = {"name": "Ana"}
316316
masking_rules = {"$.missing_field": {"dynamic_mask": True}}
317317

318+
# WHEN applying the masking rule
319+
with pytest.raises(DataMaskingFieldNotFoundError, match=r"Field or expression .* not found"):
320+
data_masker.erase(data=data, masking_rules=masking_rules)
321+
322+
323+
def test_no_matches_for_masking_rule_warning():
324+
# GIVEN a dictionary without the expected field
325+
data_masker = DataMasking(raise_on_missing_field=False)
326+
data = {"name": "Ana"}
327+
masking_rules = {"$.missing_field": {"dynamic_mask": True}}
328+
318329
# WHEN applying the masking rule
319330
with pytest.warns(UserWarning, match=r"No matches found *"):
320331
result = data_masker.erase(data=data, masking_rules=masking_rules)
@@ -323,7 +334,7 @@ def test_no_matches_for_masking_rule(data_masker):
323334
assert result == data
324335

325336

326-
def test_warning_during_masking_value(data_masker):
337+
def test_error_during_masking_value(data_masker):
327338
# GIVEN data and a masking rule
328339
data = {"value": "test"}
329340

@@ -335,11 +346,11 @@ def erase(self, value, **kwargs):
335346
data_masker.provider = MockProvider()
336347

337348
# WHEN erase is called
338-
with pytest.warns(expected_warning=PowertoolsUserWarning, match="Error masking value for path value: Mock error"):
339-
masked_data = data_masker.erase(data, masking_rules={"value": {"rule": "value"}})
349+
with pytest.raises(DataMaskingError, match="Failed to mask field at path: value") as exc_info:
350+
data_masker.erase(data, masking_rules={"value": {"rule": "value"}})
340351

341-
# THEN the original data should remain unchanged
342-
assert masked_data["value"] == "test"
352+
# THEN the provider error is preserved
353+
assert isinstance(exc_info.value.__cause__, ValueError)
343354

344355

345356
def test_mask_nested_field_success(data_masker):
@@ -456,14 +467,12 @@ def test_erase_handles_invalid_regex_pattern(data_masker):
456467
data = "test123"
457468

458469
# WHEN masking with invalid regex
459-
result = data_masker.erase(
460-
data,
461-
regex_pattern="[",
462-
mask_format="X", # Invalid regex pattern that will raise re.error
463-
)
464-
465-
# THEN original data should be returned
466-
assert result == "test123"
470+
with pytest.raises(DataMaskingError, match="Invalid regex pattern"):
471+
data_masker.erase(
472+
data,
473+
regex_pattern="[",
474+
mask_format="X", # Invalid regex pattern that will raise re.error
475+
)
467476

468477

469478
def test_erase_handles_empty_string_with_dynamic_mask(data_masker):
@@ -485,6 +494,5 @@ def test_erase_dictionary_with_masking_rules_wrong_field(data_masker):
485494
masking_rules = {"user.ssn...": {"custom_mask": "XXX-XX-XXXX"}, "user.address.zip": {"custom_mask": "00000"}}
486495

487496
# WHEN erase is called with wrong masking rules
488-
# We must have a warning
489-
with pytest.warns(expected_warning=PowertoolsUserWarning, match="Error processing path*"):
497+
with pytest.raises(DataMaskingError, match="Invalid masking path"):
490498
data_masker.erase(data, masking_rules=masking_rules)

0 commit comments

Comments
 (0)