Navegador

navegador / tests / test_security.py
Blame History Raw 362 lines
1
"""Tests for navegador.security — sensitive content detection and redaction."""
2
3
import json
4
from pathlib import Path
5
from unittest.mock import MagicMock, patch
6
7
import pytest
8
from click.testing import CliRunner
9
10
from navegador.security import REDACTED, SensitiveContentDetector, SensitiveMatch
11
12
13
# ---------------------------------------------------------------------------
14
# Fixtures
15
# ---------------------------------------------------------------------------
16
17
18
@pytest.fixture()
19
def detector():
20
return SensitiveContentDetector()
21
22
23
# ---------------------------------------------------------------------------
24
# Pattern detection tests
25
# ---------------------------------------------------------------------------
26
27
28
class TestAPIKeyDetection:
29
def test_aws_akia_key(self, detector):
30
text = "key = AKIAIOSFODNN7EXAMPLE"
31
matches = detector.scan_content(text)
32
names = [m.pattern_name for m in matches]
33
assert "aws_access_key" in names
34
35
def test_aws_asia_key(self, detector):
36
# ASIA prefix + exactly 16 uppercase alphanumeric chars = 20-char key
37
text = "assume_role_key=ASIAIOSFODNN7EXAMPLE"
38
matches = detector.scan_content(text)
39
names = [m.pattern_name for m in matches]
40
assert "aws_access_key" in names
41
42
def test_github_token_ghp(self, detector):
43
text = "GITHUB_TOKEN=ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ123456789012"
44
matches = detector.scan_content(text)
45
names = [m.pattern_name for m in matches]
46
assert "github_token" in names
47
48
def test_openai_sk_key(self, detector):
49
text = 'api_key = "sk-abcdefghijklmnopqrstuvwxyz12345678901234567890"'
50
matches = detector.scan_content(text)
51
names = [m.pattern_name for m in matches]
52
assert "api_key_sk" in names
53
54
def test_generic_api_key_assignment(self, detector):
55
text = 'API_KEY = "AbCdEfGhIjKlMnOpQrStUvWxYz123456"'
56
matches = detector.scan_content(text)
57
names = [m.pattern_name for m in matches]
58
assert "api_key_assignment" in names
59
60
def test_severity_is_high_for_aws_key(self, detector):
61
text = "AKIAIOSFODNN7EXAMPLE"
62
matches = detector.scan_content(text)
63
assert any(m.severity == "high" for m in matches)
64
65
def test_match_text_is_redacted(self, detector):
66
text = "AKIAIOSFODNN7EXAMPLE"
67
matches = detector.scan_content(text)
68
assert all(m.match_text == REDACTED for m in matches)
69
70
def test_line_number_is_correct(self, detector):
71
text = "# header\nAKIAIOSFODNN7EXAMPLE\n# footer"
72
matches = detector.scan_content(text)
73
aws_matches = [m for m in matches if m.pattern_name == "aws_access_key"]
74
assert len(aws_matches) >= 1
75
assert aws_matches[0].line_number == 2
76
77
78
class TestPasswordDetection:
79
def test_password_equals_string(self, detector):
80
text = 'password = "super_s3cr3t_pass"'
81
matches = detector.scan_content(text)
82
names = [m.pattern_name for m in matches]
83
assert "password_assignment" in names
84
85
def test_passwd_variant(self, detector):
86
text = "passwd = 'hunter2hunter2'"
87
matches = detector.scan_content(text)
88
names = [m.pattern_name for m in matches]
89
assert "password_assignment" in names
90
91
def test_secret_key_variant(self, detector):
92
text = 'secret = "mysecretvalue123"'
93
matches = detector.scan_content(text)
94
names = [m.pattern_name for m in matches]
95
assert "password_assignment" in names
96
97
def test_severity_high(self, detector):
98
text = 'password = "hunter2hunter2"'
99
matches = detector.scan_content(text)
100
pw = [m for m in matches if m.pattern_name == "password_assignment"]
101
assert all(m.severity == "high" for m in pw)
102
103
104
class TestPrivateKeyDetection:
105
def test_rsa_private_key_header(self, detector):
106
text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
107
matches = detector.scan_content(text)
108
names = [m.pattern_name for m in matches]
109
assert "private_key_pem" in names
110
111
def test_generic_private_key_header(self, detector):
112
text = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w...\n-----END PRIVATE KEY-----"
113
matches = detector.scan_content(text)
114
names = [m.pattern_name for m in matches]
115
assert "private_key_pem" in names
116
117
def test_openssh_private_key_header(self, detector):
118
text = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1...\n-----END OPENSSH PRIVATE KEY-----"
119
matches = detector.scan_content(text)
120
names = [m.pattern_name for m in matches]
121
assert "private_key_pem" in names
122
123
def test_severity_high(self, detector):
124
text = "-----BEGIN RSA PRIVATE KEY-----"
125
matches = detector.scan_content(text)
126
pk = [m for m in matches if m.pattern_name == "private_key_pem"]
127
assert all(m.severity == "high" for m in pk)
128
129
130
class TestConnectionStringDetection:
131
def test_postgres_with_credentials(self, detector):
132
text = 'DATABASE_URL = "postgresql://admin:[email protected]:5432/mydb"'
133
matches = detector.scan_content(text)
134
names = [m.pattern_name for m in matches]
135
assert "connection_string" in names
136
137
def test_mysql_with_credentials(self, detector):
138
text = "conn = mysql://user:passw0rd@localhost/schema"
139
matches = detector.scan_content(text)
140
names = [m.pattern_name for m in matches]
141
assert "connection_string" in names
142
143
def test_mongodb_with_credentials(self, detector):
144
text = 'uri = "mongodb://root:[email protected]:27017/db"'
145
matches = detector.scan_content(text)
146
names = [m.pattern_name for m in matches]
147
assert "connection_string" in names
148
149
def test_mongodb_srv_with_credentials(self, detector):
150
text = 'uri = "mongodb+srv://admin:[email protected]/mydb"'
151
matches = detector.scan_content(text)
152
names = [m.pattern_name for m in matches]
153
assert "connection_string" in names
154
155
def test_severity_high(self, detector):
156
text = "postgresql://admin:[email protected]/mydb"
157
matches = detector.scan_content(text)
158
cs = [m for m in matches if m.pattern_name == "connection_string"]
159
assert all(m.severity == "high" for m in cs)
160
161
162
class TestJWTDetection:
163
def test_valid_jwt(self, detector):
164
# A real-looking but fake JWT
165
header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
166
payload = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
167
signature = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
168
jwt = f"{header}.{payload}.{signature}"
169
text = f'Authorization: Bearer {jwt}'
170
matches = detector.scan_content(text)
171
names = [m.pattern_name for m in matches]
172
assert "jwt_token" in names
173
174
def test_severity_medium(self, detector):
175
header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
176
payload = "eyJzdWIiOiIxMjM0NTY3ODkwIn0"
177
sig = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
178
text = f"{header}.{payload}.{sig}"
179
matches = detector.scan_content(text)
180
jwt = [m for m in matches if m.pattern_name == "jwt_token"]
181
assert all(m.severity == "medium" for m in jwt)
182
183
184
# ---------------------------------------------------------------------------
185
# Redaction tests
186
# ---------------------------------------------------------------------------
187
188
189
class TestRedaction:
190
def test_redact_aws_key(self, detector):
191
text = "key = AKIAIOSFODNN7EXAMPLE"
192
result = detector.redact(text)
193
assert "AKIAIOSFODNN7EXAMPLE" not in result
194
assert REDACTED in result
195
196
def test_redact_password(self, detector):
197
text = 'password = "hunter2hunter2"'
198
result = detector.redact(text)
199
assert "hunter2hunter2" not in result
200
assert REDACTED in result
201
202
def test_redact_pem_header(self, detector):
203
text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----"
204
result = detector.redact(text)
205
assert "-----BEGIN RSA PRIVATE KEY-----" not in result
206
assert REDACTED in result
207
208
def test_redact_connection_string(self, detector):
209
text = "postgresql://admin:[email protected]/mydb"
210
result = detector.redact(text)
211
assert "s3cret" not in result
212
assert REDACTED in result
213
214
def test_redact_jwt(self, detector):
215
header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
216
payload = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ"
217
sig = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
218
jwt = f"{header}.{payload}.{sig}"
219
result = detector.redact(jwt)
220
assert jwt not in result
221
assert REDACTED in result
222
223
def test_redact_returns_unchanged_clean_text(self, detector):
224
text = "def hello():\n return 'world'\n"
225
result = detector.redact(text)
226
assert result == text
227
228
def test_redact_multiple_secrets_in_one_string(self, detector):
229
text = (
230
"AKIAIOSFODNN7EXAMPLE\n"
231
'password = "mysecretvalue"\n'
232
)
233
result = detector.redact(text)
234
assert "AKIAIOSFODNN7EXAMPLE" not in result
235
assert "mysecretvalue" not in result
236
237
238
# ---------------------------------------------------------------------------
239
# scan_file tests
240
# ---------------------------------------------------------------------------
241
242
243
class TestScanFile:
244
def test_scan_file_detects_secrets(self, detector, tmp_path):
245
secret_file = tmp_path / "config.py"
246
secret_file.write_text('AWS_KEY = "AKIAIOSFODNN7EXAMPLE"\n', encoding="utf-8")
247
matches = detector.scan_file(secret_file)
248
assert len(matches) >= 1
249
assert any(m.pattern_name == "aws_access_key" for m in matches)
250
251
def test_scan_file_clean_file(self, detector, tmp_path):
252
clean_file = tmp_path / "utils.py"
253
clean_file.write_text("def add(a, b):\n return a + b\n", encoding="utf-8")
254
matches = detector.scan_file(clean_file)
255
assert matches == []
256
257
def test_scan_file_missing_file_returns_empty(self, detector, tmp_path):
258
missing = tmp_path / "does_not_exist.py"
259
matches = detector.scan_file(missing)
260
assert matches == []
261
262
263
# ---------------------------------------------------------------------------
264
# No false positives on clean code
265
# ---------------------------------------------------------------------------
266
267
268
class TestNoFalsePositives:
269
CLEAN_SNIPPETS = [
270
# Normal variable names
271
"password_length = 12\npassword_complexity = True\n",
272
# Password prompt (no literal value)
273
"password = input('Enter password: ')\n",
274
# Short strings (below minimum length threshold)
275
"secret = 'abc'\n",
276
# Postgres URL without credentials
277
"DB_URL = 'postgresql://localhost/mydb'\n",
278
# A function named after a key concept
279
"def get_api_key_name():\n return 'key_name'\n",
280
# Normal assignment that looks vaguely like an env var
281
"API_BASE_URL = 'https://api.example.com'\n",
282
# JWT-shaped but too short / clearly not a real token
283
"token = 'eyJ.x.y'\n",
284
]
285
286
@pytest.mark.parametrize("snippet", CLEAN_SNIPPETS)
287
def test_no_false_positive(self, detector, snippet):
288
matches = detector.scan_content(snippet)
289
assert matches == [], f"Unexpected match in: {snippet!r} → {matches}"
290
291
292
# ---------------------------------------------------------------------------
293
# SensitiveMatch dataclass
294
# ---------------------------------------------------------------------------
295
296
297
class TestSensitiveMatch:
298
def test_fields(self):
299
m = SensitiveMatch(
300
pattern_name="aws_access_key",
301
line_number=3,
302
match_text=REDACTED,
303
severity="high",
304
)
305
assert m.pattern_name == "aws_access_key"
306
assert m.line_number == 3
307
assert m.match_text == REDACTED
308
assert m.severity == "high"
309
310
311
# ---------------------------------------------------------------------------
312
# CLI --redact flag
313
# ---------------------------------------------------------------------------
314
315
316
class TestCLIRedactFlag:
317
def test_redact_flag_accepted(self):
318
"""--redact flag should be accepted by the ingest command without error."""
319
from navegador.cli.commands import main
320
321
runner = CliRunner()
322
with runner.isolated_filesystem():
323
Path("src").mkdir()
324
with patch("navegador.cli.commands._get_store", return_value=MagicMock()), \
325
patch("navegador.ingestion.RepoIngester") as MockRI:
326
MockRI.return_value.ingest.return_value = {"files": 1, "functions": 2,
327
"classes": 0, "edges": 3, "skipped": 0}
328
result = runner.invoke(main, ["ingest", "src", "--redact"])
329
assert result.exit_code == 0
330
331
def test_redact_flag_passes_to_ingester(self):
332
"""RepoIngester must be constructed with redact=True when --redact is given."""
333
from navegador.cli.commands import main
334
335
runner = CliRunner()
336
with runner.isolated_filesystem():
337
Path("src").mkdir()
338
with patch("navegador.cli.commands._get_store", return_value=MagicMock()), \
339
patch("navegador.ingestion.RepoIngester") as MockRI:
340
MockRI.return_value.ingest.return_value = {"files": 0, "functions": 0,
341
"classes": 0, "edges": 0, "skipped": 0}
342
runner.invoke(main, ["ingest", "src", "--redact"])
343
MockRI.assert_called_once()
344
_, kwargs = MockRI.call_args
345
assert kwargs.get("redact") is True
346
347
def test_no_redact_flag_defaults_false(self):
348
"""Without --redact, RepoIngester should be constructed with redact=False (default)."""
349
from navegador.cli.commands import main
350
351
runner = CliRunner()
352
with runner.isolated_filesystem():
353
Path("src").mkdir()
354
with patch("navegador.cli.commands._get_store", return_value=MagicMock()), \
355
patch("navegador.ingestion.RepoIngester") as MockRI:
356
MockRI.return_value.ingest.return_value = {"files": 0, "functions": 0,
357
"classes": 0, "edges": 0, "skipped": 0}
358
runner.invoke(main, ["ingest", "src"])
359
MockRI.assert_called_once()
360
_, kwargs = MockRI.call_args
361
assert kwargs.get("redact", False) is False
362

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button