|
| 1 | +"""Table-driven tests for the validation/secrets module.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import pytest |
| 6 | + |
| 7 | +from har_capture.validation.secrets import ( |
| 8 | + Finding, |
| 9 | + check_headers, |
| 10 | + check_post_data, |
| 11 | + is_cookie_attributes_only, |
| 12 | + is_private_ip, |
| 13 | + is_redacted, |
| 14 | + truncate, |
| 15 | +) |
| 16 | + |
| 17 | + |
| 18 | +# ┌─────────────────────────────────────────────────────────────────────────────┐ |
| 19 | +# │ is_redacted() test cases │ |
| 20 | +# ├─────────────────────────────────────┬───────────┬───────────────────────────┤ |
| 21 | +# │ value │ expected │ description │ |
| 22 | +# ├─────────────────────────────────────┼───────────┼───────────────────────────┤ |
| 23 | +# │ "[REDACTED]" │ True │ standard redaction │ |
| 24 | +# │ "REDACTED" │ True │ plain REDACTED │ |
| 25 | +# │ "xxx REDACTED xxx" │ True │ contains REDACTED │ |
| 26 | +# │ "XXXX" │ True │ X placeholder │ |
| 27 | +# │ "XXXXXXXXXX" │ True │ long X placeholder │ |
| 28 | +# │ "000000" │ True │ all zeros (6+) │ |
| 29 | +# │ "0000000000" │ True │ all zeros (10) │ |
| 30 | +# │ "XX:XX:XX:XX:XX:XX" │ True │ redacted MAC │ |
| 31 | +# │ "0.0.0.0" │ True │ zero IP (allowlisted) │ |
| 32 | +# │ "::" │ True │ empty IPv6 (allowlisted) │ |
| 33 | +# │ "x@x.invalid" │ True │ redacted email │ |
| 34 | +# │ "real-secret-value" │ False │ actual secret │ |
| 35 | +# │ "Bearer token123" │ False │ auth token │ |
| 36 | +# │ "password123" │ False │ password │ |
| 37 | +# │ "00000" │ False │ 5 zeros (not enough) │ |
| 38 | +# │ "XX" │ False │ 2 X's (not enough) │ |
| 39 | +# └─────────────────────────────────────┴───────────┴───────────────────────────┘ |
| 40 | +# |
| 41 | +# fmt: off |
| 42 | +REDACTED_CASES = [ |
| 43 | + ("[REDACTED]", True, "standard redaction"), |
| 44 | + ("REDACTED", True, "plain REDACTED"), |
| 45 | + ("xxx REDACTED xxx", True, "contains REDACTED"), |
| 46 | + ("XXXX", True, "X placeholder (4+)"), |
| 47 | + ("XXXXXXXXXX", True, "long X placeholder"), |
| 48 | + ("000000", True, "all zeros (6)"), |
| 49 | + ("0000000000", True, "all zeros (10)"), |
| 50 | + ("XX:XX:XX:XX:XX:XX", True, "redacted MAC"), |
| 51 | + ("0.0.0.0", True, "zero IP (allowlisted)"), |
| 52 | + ("::", True, "empty IPv6 (allowlisted)"), |
| 53 | + ("x@x.invalid", True, "redacted email"), |
| 54 | + ("real-secret-value", False, "actual secret"), |
| 55 | + ("Bearer token123", False, "auth token"), |
| 56 | + ("password123", False, "password"), |
| 57 | + ("00000", False, "5 zeros (not enough)"), |
| 58 | +] |
| 59 | +# fmt: on |
| 60 | + |
| 61 | + |
| 62 | +@pytest.mark.parametrize("value,expected,desc", REDACTED_CASES) |
| 63 | +def test_is_redacted(value: str, expected: bool, desc: str) -> None: |
| 64 | + """Test is_redacted() with various values.""" |
| 65 | + result = is_redacted(value) |
| 66 | + assert result == expected, f"Failed for {desc}: {value}" |
| 67 | + |
| 68 | + |
| 69 | +# ┌─────────────────────────────────────────────────────────────────────────────┐ |
| 70 | +# │ is_cookie_attributes_only() test cases │ |
| 71 | +# ├─────────────────────────────────────┬───────────┬───────────────────────────┤ |
| 72 | +# │ value │ expected │ description │ |
| 73 | +# ├─────────────────────────────────────┼───────────┼───────────────────────────┤ |
| 74 | +# │ "Secure" │ True │ just Secure │ |
| 75 | +# │ "HttpOnly" │ True │ just HttpOnly │ |
| 76 | +# │ "Secure; HttpOnly" │ True │ both attributes │ |
| 77 | +# │ "HttpOnly; Secure" │ True │ both reversed │ |
| 78 | +# │ "" │ True │ empty string │ |
| 79 | +# │ " " │ True │ whitespace only │ |
| 80 | +# │ "session=abc123" │ False │ actual session value │ |
| 81 | +# │ "Secure; session=abc" │ False │ attributes + value │ |
| 82 | +# │ "token=xyz; HttpOnly" │ False │ value + attributes │ |
| 83 | +# └─────────────────────────────────────┴───────────┴───────────────────────────┘ |
| 84 | +# |
| 85 | +# fmt: off |
| 86 | +COOKIE_ATTR_CASES = [ |
| 87 | + ("Secure", True, "just Secure"), |
| 88 | + ("HttpOnly", True, "just HttpOnly"), |
| 89 | + ("Secure; HttpOnly", True, "both attributes"), |
| 90 | + ("HttpOnly; Secure", True, "both reversed"), |
| 91 | + ("", True, "empty string"), |
| 92 | + (" ", True, "whitespace only"), |
| 93 | + ("session=abc123", False, "actual session value"), |
| 94 | + ("Secure; session=abc", False, "attributes + value"), |
| 95 | + ("token=xyz; HttpOnly", False, "value + attributes"), |
| 96 | +] |
| 97 | +# fmt: on |
| 98 | + |
| 99 | + |
| 100 | +@pytest.mark.parametrize("value,expected,desc", COOKIE_ATTR_CASES) |
| 101 | +def test_is_cookie_attributes_only(value: str, expected: bool, desc: str) -> None: |
| 102 | + """Test is_cookie_attributes_only() with various values.""" |
| 103 | + result = is_cookie_attributes_only(value) |
| 104 | + assert result == expected, f"Failed for {desc}: {value}" |
| 105 | + |
| 106 | + |
| 107 | +# ┌─────────────────────────────────────────────────────────────────────────────┐ |
| 108 | +# │ is_private_ip() test cases │ |
| 109 | +# ├─────────────────────────────────────┬───────────┬───────────────────────────┤ |
| 110 | +# │ ip │ expected │ description │ |
| 111 | +# ├─────────────────────────────────────┼───────────┼───────────────────────────┤ |
| 112 | +# │ "10.0.0.1" │ True │ 10.x.x.x range │ |
| 113 | +# │ "10.255.255.255" │ True │ 10.x end │ |
| 114 | +# │ "172.16.0.1" │ True │ 172.16.x start │ |
| 115 | +# │ "172.31.255.255" │ True │ 172.31.x end │ |
| 116 | +# │ "192.168.0.1" │ True │ 192.168.x.x │ |
| 117 | +# │ "192.168.100.1" │ True │ 192.168.x.x │ |
| 118 | +# │ "127.0.0.1" │ True │ localhost │ |
| 119 | +# │ "0.0.0.0" │ True │ all zeros (redacted) │ |
| 120 | +# │ "8.8.8.8" │ False │ Google DNS (public) │ |
| 121 | +# │ "1.1.1.1" │ False │ Cloudflare (public) │ |
| 122 | +# │ "172.15.0.1" │ False │ just outside 172.16-31 │ |
| 123 | +# │ "172.32.0.1" │ False │ just outside 172.16-31 │ |
| 124 | +# │ "192.167.0.1" │ False │ not 192.168 │ |
| 125 | +# │ "invalid" │ False │ not an IP │ |
| 126 | +# │ "256.0.0.1" │ False │ invalid octet │ |
| 127 | +# │ "1.2.3" │ False │ too few octets │ |
| 128 | +# └─────────────────────────────────────┴───────────┴───────────────────────────┘ |
| 129 | +# |
| 130 | +# fmt: off |
| 131 | +PRIVATE_IP_CASES = [ |
| 132 | + ("10.0.0.1", True, "10.x.x.x range"), |
| 133 | + ("10.255.255.255", True, "10.x end"), |
| 134 | + ("172.16.0.1", True, "172.16.x start"), |
| 135 | + ("172.31.255.255", True, "172.31.x end"), |
| 136 | + ("192.168.0.1", True, "192.168.x.x"), |
| 137 | + ("192.168.100.1", True, "192.168.x.x"), |
| 138 | + ("127.0.0.1", True, "localhost"), |
| 139 | + ("0.0.0.0", True, "all zeros (redacted)"), |
| 140 | + ("8.8.8.8", False, "Google DNS (public)"), |
| 141 | + ("1.1.1.1", False, "Cloudflare (public)"), |
| 142 | + ("172.15.0.1", False, "just outside 172.16-31"), |
| 143 | + ("172.32.0.1", False, "just outside 172.16-31"), |
| 144 | + ("192.167.0.1", False, "not 192.168"), |
| 145 | + ("invalid", False, "not an IP"), |
| 146 | + ("256.0.0.1", False, "invalid octet"), |
| 147 | + ("1.2.3", False, "too few octets"), |
| 148 | +] |
| 149 | +# fmt: on |
| 150 | + |
| 151 | + |
| 152 | +@pytest.mark.parametrize("ip,expected,desc", PRIVATE_IP_CASES) |
| 153 | +def test_is_private_ip(ip: str, expected: bool, desc: str) -> None: |
| 154 | + """Test is_private_ip() with various IPs.""" |
| 155 | + result = is_private_ip(ip) |
| 156 | + assert result == expected, f"Failed for {desc}: {ip}" |
| 157 | + |
| 158 | + |
| 159 | +# ┌─────────────────────────────────────────────────────────────────────────────┐ |
| 160 | +# │ truncate() test cases │ |
| 161 | +# ├─────────────────────────────────────┬───────────┬───────────────────────────┤ |
| 162 | +# │ value │ max_len │ expected_suffix │ |
| 163 | +# ├─────────────────────────────────────┼───────────┼───────────────────────────┤ |
| 164 | +# │ "short" │ 40 │ "short" (unchanged) │ |
| 165 | +# │ "x" * 40 │ 40 │ exact (unchanged) │ |
| 166 | +# │ "x" * 50 │ 40 │ ends with "..." │ |
| 167 | +# │ "abcdefghij" │ 5 │ "ab..." │ |
| 168 | +# └─────────────────────────────────────┴───────────┴───────────────────────────┘ |
| 169 | +# |
| 170 | +# fmt: off |
| 171 | +TRUNCATE_CASES = [ |
| 172 | + ("short", 40, "short", "under limit unchanged"), |
| 173 | + ("x" * 40, 40, "x" * 40, "exact limit unchanged"), |
| 174 | + ("x" * 50, 40, "x" * 37 + "...", "over limit truncated"), |
| 175 | + ("abcdefghij", 5, "ab...", "custom limit"), |
| 176 | +] |
| 177 | +# fmt: on |
| 178 | + |
| 179 | + |
| 180 | +@pytest.mark.parametrize("value,max_len,expected,desc", TRUNCATE_CASES) |
| 181 | +def test_truncate(value: str, max_len: int, expected: str, desc: str) -> None: |
| 182 | + """Test truncate() with various values.""" |
| 183 | + result = truncate(value, max_len) |
| 184 | + assert result == expected, f"Failed for {desc}" |
| 185 | + |
| 186 | + |
| 187 | +class TestCheckHeaders: |
| 188 | + """Tests for check_headers() function.""" |
| 189 | + |
| 190 | + def test_detects_authorization_header(self) -> None: |
| 191 | + """Test detection of Authorization header with real value.""" |
| 192 | + headers = [{"name": "Authorization", "value": "Bearer secret-token"}] |
| 193 | + findings: list[Finding] = [] |
| 194 | + check_headers(headers, "request", findings) |
| 195 | + assert len(findings) == 1 |
| 196 | + assert findings[0].severity == "error" |
| 197 | + assert "authorization" in findings[0].reason.lower() |
| 198 | + |
| 199 | + def test_ignores_redacted_authorization(self) -> None: |
| 200 | + """Test redacted Authorization header is not flagged.""" |
| 201 | + headers = [{"name": "Authorization", "value": "[REDACTED]"}] |
| 202 | + findings: list[Finding] = [] |
| 203 | + check_headers(headers, "request", findings) |
| 204 | + assert len(findings) == 0 |
| 205 | + |
| 206 | + def test_detects_cookie_header(self) -> None: |
| 207 | + """Test detection of Cookie header with session data.""" |
| 208 | + headers = [{"name": "Cookie", "value": "session=abc123xyz"}] |
| 209 | + findings: list[Finding] = [] |
| 210 | + check_headers(headers, "request", findings) |
| 211 | + assert len(findings) == 1 |
| 212 | + assert "Cookie" in findings[0].field or "cookie" in findings[0].reason.lower() |
| 213 | + |
| 214 | + def test_ignores_cookie_attributes_only(self) -> None: |
| 215 | + """Test Cookie with only attributes is not flagged.""" |
| 216 | + headers = [{"name": "Cookie", "value": "Secure; HttpOnly"}] |
| 217 | + findings: list[Finding] = [] |
| 218 | + check_headers(headers, "request", findings) |
| 219 | + assert len(findings) == 0 |
| 220 | + |
| 221 | + def test_ignores_empty_header_value(self) -> None: |
| 222 | + """Test empty header value is not flagged.""" |
| 223 | + headers = [{"name": "Authorization", "value": ""}] |
| 224 | + findings: list[Finding] = [] |
| 225 | + check_headers(headers, "request", findings) |
| 226 | + assert len(findings) == 0 |
| 227 | + |
| 228 | + def test_detects_set_cookie_header(self) -> None: |
| 229 | + """Test detection of Set-Cookie header.""" |
| 230 | + headers = [{"name": "Set-Cookie", "value": "session=xyz; Path=/"}] |
| 231 | + findings: list[Finding] = [] |
| 232 | + check_headers(headers, "response", findings) |
| 233 | + assert len(findings) == 1 |
| 234 | + |
| 235 | + def test_multiple_sensitive_headers(self) -> None: |
| 236 | + """Test detection of multiple sensitive headers.""" |
| 237 | + headers = [ |
| 238 | + {"name": "Authorization", "value": "Bearer token"}, |
| 239 | + {"name": "Cookie", "value": "session=abc"}, |
| 240 | + ] |
| 241 | + findings: list[Finding] = [] |
| 242 | + check_headers(headers, "request", findings) |
| 243 | + assert len(findings) == 2 |
| 244 | + |
| 245 | + |
| 246 | +class TestCheckPostData: |
| 247 | + """Tests for check_post_data() function.""" |
| 248 | + |
| 249 | + def test_detects_password_in_params(self) -> None: |
| 250 | + """Test detection of password field in form params.""" |
| 251 | + post_data = { |
| 252 | + "params": [{"name": "password", "value": "mysecret123"}], |
| 253 | + "mimeType": "application/x-www-form-urlencoded", |
| 254 | + } |
| 255 | + findings: list[Finding] = [] |
| 256 | + check_post_data(post_data, "request", findings) |
| 257 | + assert len(findings) == 1 |
| 258 | + assert findings[0].severity == "error" |
| 259 | + assert "password" in findings[0].field.lower() |
| 260 | + |
| 261 | + def test_ignores_redacted_password(self) -> None: |
| 262 | + """Test redacted password is not flagged.""" |
| 263 | + post_data = { |
| 264 | + "params": [{"name": "password", "value": "[REDACTED]"}], |
| 265 | + "mimeType": "application/x-www-form-urlencoded", |
| 266 | + } |
| 267 | + findings: list[Finding] = [] |
| 268 | + check_post_data(post_data, "request", findings) |
| 269 | + assert len(findings) == 0 |
| 270 | + |
| 271 | + def test_handles_empty_post_data(self) -> None: |
| 272 | + """Test empty post data doesn't cause errors.""" |
| 273 | + findings: list[Finding] = [] |
| 274 | + check_post_data(None, "request", findings) |
| 275 | + assert len(findings) == 0 |
| 276 | + check_post_data({}, "request", findings) |
| 277 | + assert len(findings) == 0 |
| 278 | + |
| 279 | + def test_detects_token_field(self) -> None: |
| 280 | + """Test detection of token field.""" |
| 281 | + post_data = { |
| 282 | + "params": [{"name": "access_token", "value": "abc123xyz"}], |
| 283 | + "mimeType": "application/x-www-form-urlencoded", |
| 284 | + } |
| 285 | + findings: list[Finding] = [] |
| 286 | + check_post_data(post_data, "request", findings) |
| 287 | + assert len(findings) == 1 |
| 288 | + |
| 289 | + def test_detects_credential_field(self) -> None: |
| 290 | + """Test detection of credential field.""" |
| 291 | + post_data = { |
| 292 | + "params": [{"name": "user_credential", "value": "secret"}], |
| 293 | + "mimeType": "application/x-www-form-urlencoded", |
| 294 | + } |
| 295 | + findings: list[Finding] = [] |
| 296 | + check_post_data(post_data, "request", findings) |
| 297 | + assert len(findings) == 1 |
| 298 | + |
| 299 | + |
| 300 | +class TestFindingDataclass: |
| 301 | + """Tests for Finding dataclass.""" |
| 302 | + |
| 303 | + def test_finding_creation(self) -> None: |
| 304 | + """Test Finding can be created with all fields.""" |
| 305 | + finding = Finding( |
| 306 | + severity="error", |
| 307 | + location="request.headers", |
| 308 | + field="Authorization", |
| 309 | + value="Bearer xxx...", |
| 310 | + reason="Sensitive header", |
| 311 | + ) |
| 312 | + assert finding.severity == "error" |
| 313 | + assert finding.location == "request.headers" |
| 314 | + assert finding.field == "Authorization" |
| 315 | + assert finding.value == "Bearer xxx..." |
| 316 | + assert finding.reason == "Sensitive header" |
| 317 | + |
| 318 | + def test_finding_warning_severity(self) -> None: |
| 319 | + """Test Finding with warning severity.""" |
| 320 | + finding = Finding( |
| 321 | + severity="warning", |
| 322 | + location="response.content", |
| 323 | + field="email", |
| 324 | + value="user@example.com", |
| 325 | + reason="Potential email address", |
| 326 | + ) |
| 327 | + assert finding.severity == "warning" |
0 commit comments