| 1 | # Feature: desktop-workflow-polish, Property F: stderr / config redactor never leaks credentials |
| 2 | """Hypothesis property tests for ``utils.notifier`` credential redactors. |
| 3 | |
| 4 | Validates Property F: stderr / config redactor never leaks credentials |
| 5 | Validates: Requirements 3.14, 7.10 |
| 6 | |
| 7 | The two helpers under test are: |
| 8 | |
| 9 | - ``_mask_credential(value)`` — masks an individual token. |
| 10 | - ``_masked_config_for_log(type, config)`` — returns a deep-copied provider |
| 11 | config with ``bark.device_key`` / ``telegram.bot_token`` / webhook URL |
| 12 | query-string values masked. |
| 13 | |
| 14 | Properties covered for ``_mask_credential``: |
| 15 | |
| 16 | For any token ``v`` with ``len(v) >= 8``: |
| 17 | - the middle portion ``v[4:-4]`` is NOT a substring of the masked output |
| 18 | - the masked output starts with ``v[:4]`` and ends with ``v[-4:]`` |
| 19 | - the masked output contains the literal ``***`` |
| 20 | - the masked output does not grow pathologically |
| 21 | (``len(out) < len(v) + 16``) |
| 22 | |
| 23 | Properties covered for ``_masked_config_for_log`` (JSON-serialized output): |
| 24 | |
| 25 | For each provider type (bark / telegram / webhook): |
| 26 | - the original secret's middle portion never appears in |
| 27 | ``json.dumps(masked)`` — i.e. the secret cannot be recovered via a |
| 28 | JSON config dump that callers use for log redaction. |
| 29 | """ |
| 30 | |
| 31 | from __future__ import annotations |
| 32 | |
| 33 | import json |
| 34 | from urllib.parse import quote |
| 35 | |
| 36 | from hypothesis import given |
| 37 | from hypothesis import settings as hyp_settings |
| 38 | from hypothesis import strategies as st |
| 39 | |
| 40 | from utils.notifier import _mask_credential, _masked_config_for_log |
| 41 | |
| 42 | # The literal mask sentinel emitted by ``_mask_credential``. Any middle-leak |
| 43 | # assertion must tolerate substrings of this marker — otherwise a generator |
| 44 | # that happens to produce a token whose middle is only ``*`` characters will |
| 45 | # falsely flag the mask itself as a leak (e.g. token='0000*0000' produces |
| 46 | # masked='0000***0000'; the lone '*' in the middle is indistinguishable from |
| 47 | # the sentinel and is NOT a credential leak). |
| 48 | _MASK_SENTINEL = "***" |
| 49 | |
| 50 | |
| 51 | # --------------------------------------------------------------------------- |
| 52 | # Generators |
| 53 | # --------------------------------------------------------------------------- |
| 54 | |
| 55 | # Arbitrary unicode text — exercises that the redactor handles any byte-safe |
| 56 | # credential (Bark device keys, Telegram bot tokens, and webhook query values |
| 57 | # may in practice contain only ASCII, but we are defensive in depth). |
| 58 | _token_strategy = st.text(min_size=8, max_size=200) |
| 59 | |
| 60 | # Token alphabet used specifically for the webhook-URL test. Hex chars let |
| 61 | # us fuzz byte-like credentials while staying disjoint from the lowercase |
| 62 | # ASCII we use for host / path / param names, so the middle-leak assertion |
| 63 | # measures what it's supposed to — an actual leak of the middle token bytes |
| 64 | # — instead of an accidental substring match against the URL structure. |
| 65 | _webhook_token_strategy = st.text( |
| 66 | alphabet="0123456789abcdef", |
| 67 | min_size=16, |
| 68 | max_size=200, |
| 69 | ) |
| 70 | |
| 71 | # Safe alphabet for query param names + URL paths/hosts so that a generated |
| 72 | # URL is guaranteed to round-trip through ``urlsplit`` / ``urlencode`` without |
| 73 | # the helper choosing to encode special characters inside the masked value. |
| 74 | # Intentionally excludes hex digits (0-9a-f) so `_webhook_token_strategy` and |
| 75 | # these never collide in a way that would produce spurious failures. |
| 76 | _url_safe_alphabet = "ghijklmnopqrstuvwxyzGHIJKLMNOPQRSTUVWXYZ-_." |
| 77 | _url_safe_text = st.text(alphabet=_url_safe_alphabet, min_size=1, max_size=20) |
| 78 | |
| 79 | |
| 80 | # --------------------------------------------------------------------------- |
| 81 | # Property: _mask_credential |
| 82 | # --------------------------------------------------------------------------- |
| 83 | |
| 84 | |
| 85 | @given(value=_token_strategy) |
| 86 | @hyp_settings(max_examples=100) |
| 87 | def test_mask_credential_never_leaks_middle(value: str) -> None: |
| 88 | """For any ``v`` with ``len(v) >= 8`` the middle ``v[4:-4]`` is scrubbed. |
| 89 | |
| 90 | Additionally: the masked output keeps the 4-char prefix and 4-char suffix, |
| 91 | contains ``'***'``, and does not expand by more than 16 characters. |
| 92 | """ |
| 93 | out = _mask_credential(value) |
| 94 | |
| 95 | assert isinstance(out, str) |
| 96 | assert "***" in out |
| 97 | assert out.startswith(value[:4]) |
| 98 | assert out.endswith(value[-4:]) |
| 99 | |
| 100 | middle = value[4:-4] |
| 101 | # Skip the middle-leak assertion when the middle is unavoidably a |
| 102 | # substring of (a) the preserved 4+4 edges, or (b) the mask sentinel |
| 103 | # ``***`` itself. Case (a) covers e.g. value='000000000' where |
| 104 | # middle='0' trivially appears in preserved='00000000'; case (b) covers |
| 105 | # middles consisting only of '*' characters (e.g. value='0000*0000') |
| 106 | # which are indistinguishable from the sentinel the masker emits. |
| 107 | middle_in_preserved = len(middle) > 0 and (middle in value[:4] or middle in value[-4:]) |
| 108 | middle_in_sentinel = len(middle) > 0 and middle in _MASK_SENTINEL |
| 109 | if len(middle) > 0 and not middle_in_preserved and not middle_in_sentinel: |
| 110 | # The whole middle portion must be gone. Note: a *prefix* or *suffix* |
| 111 | # of the middle can legally still appear if it happens to match the |
| 112 | # first/last 4 preserved characters, but the full span cannot. |
| 113 | assert middle not in out, f"Middle portion {middle!r} leaked into masked output {out!r}" |
| 114 | |
| 115 | # No pathological expansion: ``first4 + '***' + last4`` is 11 chars; we |
| 116 | # allow some slack but refuse the redactor growing unboundedly. |
| 117 | assert len(out) < len(value) + 16 |
| 118 | |
| 119 | |
| 120 | @given(value=st.text(max_size=7)) |
| 121 | @hyp_settings(max_examples=100) |
| 122 | def test_mask_credential_short_inputs_collapse_to_stars(value: str) -> None: |
| 123 | """Inputs shorter than 8 chars must collapse to exactly ``'***'``. |
| 124 | |
| 125 | This proves that partial redaction never accidentally reveals a short |
| 126 | secret such as a 4-char PIN. |
| 127 | """ |
| 128 | assert _mask_credential(value) == "***" |
| 129 | |
| 130 | |
| 131 | # --------------------------------------------------------------------------- |
| 132 | # Property: _masked_config_for_log — bark |
| 133 | # --------------------------------------------------------------------------- |
| 134 | |
| 135 | |
| 136 | @given(device_key=_token_strategy) |
| 137 | @hyp_settings(max_examples=100) |
| 138 | def test_masked_config_bark_device_key_never_leaks(device_key: str) -> None: |
| 139 | """Bark ``device_key`` middle portion must not appear in serialized output.""" |
| 140 | config = { |
| 141 | "type": "bark", |
| 142 | "device_key": device_key, |
| 143 | "sound": "bell", |
| 144 | } |
| 145 | masked = _masked_config_for_log("bark", config) |
| 146 | serialized = json.dumps(masked, ensure_ascii=False) |
| 147 | |
| 148 | # The original config must not have been mutated in place. |
| 149 | assert config["device_key"] == device_key |
| 150 | |
| 151 | middle = device_key[4:-4] |
| 152 | # Skip when the middle is unavoidably present in either the preserved |
| 153 | # 4+4 edges or the mask sentinel itself (see `_MASK_SENTINEL` comment |
| 154 | # at top of file for rationale). |
| 155 | middle_in_preserved = len(middle) > 0 and ( |
| 156 | middle in device_key[:4] or middle in device_key[-4:] |
| 157 | ) |
| 158 | middle_in_sentinel = len(middle) > 0 and middle in _MASK_SENTINEL |
| 159 | if len(middle) > 0 and not middle_in_preserved and not middle_in_sentinel: |
| 160 | # Check the masked field value directly, not the full JSON blob: |
| 161 | # incidental bytes in other fields (e.g. `"sound": "bell"`) could |
| 162 | # coincidentally contain the middle character and defeat the |
| 163 | # assertion without representing a credential leak. |
| 164 | assert middle not in masked["device_key"], ( |
| 165 | f"device_key middle {middle!r} leaked into masked field {masked['device_key']!r}" |
| 166 | ) |
| 167 | # Sanity: the serialized JSON exists and contains the mask marker. |
| 168 | assert "***" in serialized |
| 169 | |
| 170 | # The masked representation must still carry a marker and the short |
| 171 | # prefix so operators can recognise which key was configured. |
| 172 | assert "***" in masked["device_key"] |
| 173 | assert masked["device_key"].startswith(device_key[:4]) |
| 174 | assert masked["device_key"].endswith(device_key[-4:]) |
| 175 | |
| 176 | # Non-sensitive fields untouched. |
| 177 | assert masked["sound"] == "bell" |
| 178 | |
| 179 | |
| 180 | # --------------------------------------------------------------------------- |
| 181 | # Property: _masked_config_for_log — telegram |
| 182 | # --------------------------------------------------------------------------- |
| 183 | |
| 184 | |
| 185 | @given(bot_token=_token_strategy) |
| 186 | @hyp_settings(max_examples=100) |
| 187 | def test_masked_config_telegram_bot_token_never_leaks(bot_token: str) -> None: |
| 188 | """Telegram ``bot_token`` middle portion must not appear in serialized output.""" |
| 189 | config = { |
| 190 | "type": "telegram", |
| 191 | "bot_token": bot_token, |
| 192 | # Distinct suffix so `chat_id` can never spuriously appear in |
| 193 | # `bot_token[4:-4]` and defeat the leak assertion. |
| 194 | "chat_id": "CHAT_ID_PLACEHOLDER", |
| 195 | } |
| 196 | masked = _masked_config_for_log("telegram", config) |
| 197 | |
| 198 | assert config["bot_token"] == bot_token # no in-place mutation |
| 199 | |
| 200 | middle = bot_token[4:-4] |
| 201 | # Skip when the middle is unavoidably present in either the preserved |
| 202 | # 4+4 edges or the mask sentinel itself (see `_MASK_SENTINEL` comment |
| 203 | # at top of file for rationale). |
| 204 | middle_in_preserved = len(middle) > 0 and (middle in bot_token[:4] or middle in bot_token[-4:]) |
| 205 | middle_in_sentinel = len(middle) > 0 and middle in _MASK_SENTINEL |
| 206 | if len(middle) > 0 and not middle_in_preserved and not middle_in_sentinel: |
| 207 | # Check masked field value only — see bark test for rationale. |
| 208 | assert middle not in masked["bot_token"], ( |
| 209 | f"bot_token middle {middle!r} leaked into masked field {masked['bot_token']!r}" |
| 210 | ) |
| 211 | |
| 212 | assert "***" in masked["bot_token"] |
| 213 | assert masked["chat_id"] == "CHAT_ID_PLACEHOLDER" |
| 214 | |
| 215 | |
| 216 | # --------------------------------------------------------------------------- |
| 217 | # Property: _masked_config_for_log — webhook url query-string |
| 218 | # --------------------------------------------------------------------------- |
| 219 | |
| 220 | |
| 221 | @given( |
| 222 | token=_webhook_token_strategy, |
| 223 | host=_url_safe_text, |
| 224 | path=_url_safe_text, |
| 225 | param=_url_safe_text, |
| 226 | ) |
| 227 | @hyp_settings(max_examples=100) |
| 228 | def test_masked_config_webhook_url_query_never_leaks( |
| 229 | token: str, host: str, path: str, param: str |
| 230 | ) -> None: |
| 231 | """Webhook URL query values are masked; path/host are preserved. |
| 232 | |
| 233 | The token is URL-quoted before being placed into the query so that the |
| 234 | generated URL is always well-formed and the value inside the query string |
| 235 | is exactly the original token. After masking we check that the original |
| 236 | token's middle portion cannot be recovered from either the URL itself or |
| 237 | the JSON-serialized config. |
| 238 | """ |
| 239 | url = "https://{host}/{path}?{param}={value}".format( |
| 240 | host=host, path=path, param=param, value=quote(token, safe="") |
| 241 | ) |
| 242 | config = {"type": "webhook", "url": url} |
| 243 | masked = _masked_config_for_log("webhook", config) |
| 244 | serialized = json.dumps(masked, ensure_ascii=False) |
| 245 | |
| 246 | # Original config untouched. |
| 247 | assert config["url"] == url |
| 248 | |
| 249 | middle = token[4:-4] |
| 250 | # Same repetitive-middle caveat as the bark / telegram tests above. |
| 251 | middle_in_preserved = len(middle) > 0 and (middle in token[:4] or middle in token[-4:]) |
| 252 | if len(middle) > 0 and not middle_in_preserved: |
| 253 | assert middle not in masked["url"], ( |
| 254 | f"token middle {middle!r} leaked into masked URL {masked['url']!r}" |
| 255 | ) |
| 256 | assert middle not in serialized |
| 257 | |
| 258 | # Host + path must be preserved exactly so operators can still see which |
| 259 | # endpoint was configured. |
| 260 | assert host in masked["url"] |
| 261 | assert path in masked["url"] |
| 262 | # The param name is preserved; only its value is masked. |
| 263 | assert param + "=" in masked["url"] |
| 264 | |
| 265 | |
| 266 | # --------------------------------------------------------------------------- |
| 267 | # Property: unknown provider types leave config untouched |
| 268 | # --------------------------------------------------------------------------- |
| 269 | |
| 270 | |
| 271 | @given(secret=_token_strategy) |
| 272 | @hyp_settings(max_examples=100) |
| 273 | def test_masked_config_unknown_type_returns_deep_copy(secret: str) -> None: |
| 274 | """Unknown provider types do not crash and return a deep copy. |
| 275 | |
| 276 | This guarantees callers can route any provider type through the helper |
| 277 | without special-casing. For an unknown type we do not attempt to guess |
| 278 | which fields are sensitive; the unmodified-but-deep-copied payload is |
| 279 | returned so the caller can log or discard at its discretion. |
| 280 | """ |
| 281 | config = {"type": "unknown", "secret": secret} |
| 282 | masked = _masked_config_for_log("unknown", config) |
| 283 | |
| 284 | # Deep copy: mutating one must not affect the other. |
| 285 | masked["secret"] = "CHANGED" |
| 286 | assert config["secret"] == secret |
| 287 |