diff --git a/CHANGELOG.md b/CHANGELOG.md index 122d331..9d3ffff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +## [0.9.2] - 2020-10-19 + +### Changed + +- Allow redaction of bytes. + +## [0.9.1] - 2020-09-26 + +### Changed + - When starting a recording, remove any existing uncompressed file. ## [0.9.0] - 2020-09-24 diff --git a/interposer/tapedeck.py b/interposer/tapedeck.py index a5d09a6..90b3c47 100644 --- a/interposer/tapedeck.py +++ b/interposer/tapedeck.py @@ -20,6 +20,7 @@ from typing import Callable from typing import Dict from typing import Optional +from typing import Union import yaml @@ -175,7 +176,7 @@ def __init__(self, deck: Path, mode: Mode) -> None: # call ordinal key (channel name) and value (ordinal number) self._call_ordinals: Dict[str, int] = {} self._logger = logging.getLogger(__name__) - self._redactions: Dict[str, str] = dict() + self._redactions: Dict[Union[str, bytes], str] = dict() # the open file resource self._tape = None @@ -343,7 +344,7 @@ def playback(self, context: CallContext, channel: str = "default") -> Any: self._log_ex("playback", context, payload.ex) raise payload.ex - def redact(self, secret: str, identifier: str) -> str: + def redact(self, secret: Union[str, bytes], identifier: str) -> str: """ Auto-track secrets for redaction. @@ -366,14 +367,14 @@ def redact(self, secret: str, identifier: str) -> str: gets returned as the secret so the playback calls align with the recording. """ - if not isinstance(secret, str): - raise TypeError("secret must be a string") + if not isinstance(secret, (str, bytes)): + raise TypeError("secret must be a string or bytes") if not isinstance(identifier, str): raise TypeError("identifier must be a string") if not secret: - raise AttributeError("secret cannot be an empty string") + raise AttributeError("secret cannot be empty") if not identifier: - raise AttributeError("identifier cannot be an empty string") + raise AttributeError("identifier cannot be empty") key = f"_redact_{identifier}" @@ -397,7 +398,10 @@ def redact(self, secret: str, identifier: str) -> str: raise AttributeError( f"{identifier} was not used during recording to redact this secret" ) - return (identifier + ("_" * secretlen))[:secretlen] + result = (identifier + ("_" * secretlen))[:secretlen] + if isinstance(secret, bytes): + result = result.encode() + return result def _advance(self, context: CallContext, channel: str) -> str: """ @@ -510,7 +514,10 @@ def _log(self, level: int, category: str, action: str, msg: str) -> None: """ msg = f"TAPE: {category}({action}): {msg}" for secret, replacement in self._redactions.items(): - msg = msg.replace(secret, replacement) + if isinstance(secret, str): + msg = msg.replace(secret, replacement) + else: + msg = msg.encode().replace(secret, replacement.encode()).decode() self._logger.log(level, msg) def _log_ex(self, action: str, context: CallContext, ex: Exception) -> None: @@ -559,7 +566,10 @@ def _redact(self, entity: Any, return_bytes: bool = False) -> Any: """ raw = pickle.dumps(entity, protocol=self.PICKLE_PROTOCOL) for secret, replacement in self._redactions.items(): - raw = raw.replace(secret.encode(), replacement.encode()) + raw = raw.replace( + secret.encode() if isinstance(secret, str) else secret, + replacement.encode(), + ) return pickle.loads(raw) if not return_bytes else raw # nosec def _reduce_call(self, context: CallContext) -> Callable: diff --git a/setup.py b/setup.py index a2c1924..1f85090 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ description = "A code intercept wrapper with recording and playback options." major = 0 minor = 9 -patch = 1 +patch = 2 # Everything below should be cookie-cutter diff --git a/tests/tapedeck_test.py b/tests/tapedeck_test.py index 6cd7df7..c701be2 100644 --- a/tests/tapedeck_test.py +++ b/tests/tapedeck_test.py @@ -201,6 +201,8 @@ def test_redact(self): with self.assertRaises(AttributeError): # each identifier must be unique uut.redact("crush", "THIS") + with self.assertRaises(AttributeError): + uut.redact("foobar".encode(), "THIS") with TapeDeck(self.datadir / "recording", Mode.Playback) as uut: # playback caller may not know the secret but does know the identifier @@ -209,13 +211,14 @@ def test_redact(self): def test_recording_secrets(self): """ Tests automatic redaction of known secrets and use in playback """ - token = str(uuid.uuid4()) + token = str(uuid.uuid4()).encode() token2 = str(uuid.uuid4()) keeper = KeeperOfFineSecrets(token) # pretend someone created an object and made two calls where one succeeds and one raises with TapeDeck(self.datadir / "recording", Mode.Recording) as uut: + # use encode to test redacting bytes use_token = uut.redact(token, "REDACTED_SMALLER_THAN_ORIGINAL") assert use_token == token use_token2 = uut.redact( @@ -250,9 +253,10 @@ def test_recording_secrets(self): with TapeDeck(self.datadir / "recording", Mode.Playback) as uut: # during playback the secret passed in may not be the same as during recording - # however since it was redacted, the identifier is what's important + # however since it was redacted, the identifier is what's important; if the + # original was bytes, this one has to be bytes too redacted_token = uut.redact( - "not-the-original-token", "REDACTED_SMALLER_THAN_ORIGINAL" + "not-the-original-token".encode(), "REDACTED_SMALLER_THAN_ORIGINAL" ) assert redacted_token != token # the redaction will have the same length as the original secret @@ -280,8 +284,8 @@ def test_recording_secrets(self): assert uut.playback( CallContext(call=redacted_keeper.get_token, args=(), kwargs={}) ) - assert token not in str(ex.exception) - assert redacted_token in str(ex.exception) + assert token.decode() not in str(ex.exception) + assert redacted_token.decode() in str(ex.exception) uut.dump(self.datadir / "dump.yaml") # this identifier was never used during recording