Option C Tools
Loading...
Searching...
No Matches
test_entropy_detection.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_tools/test_entropy_detection.py
4
5"""
6Purpose
7-------
8FS-507 regression coverage for Shannon entropy-based secret detection.
9
10Responsibilities
11----------------
12- Validate ``_shannon_entropy()`` for known inputs.
13- Validate ``_check_entropy_violations()`` for AST-level detection.
14- Validate that ``check_no_hardcoded_secrets()`` integrates entropy checking.
15- Validate threshold configurability and the ``--no-entropy`` opt-out.
16
17Diagnostics
18-----------
19Domain: SECRET-SCAN-TESTS
20 Levels:
21 L2 — test lifecycle
22 L3 — assertion details
23 L4 — deep tracing
24
25Contracts
26---------
27- Tests operate on in-memory strings and AST snippets; no files are
28 written.
29"""
30
31from __future__ import annotations
32
33import ast
34import json
35import math
36import textwrap
37
38import pytest
39
40from oct.tools.secret_scanner import (
41 _shannon_entropy,
42 _check_entropy_violations,
43 _collect_docstring_node_ids,
44 _collect_keyword_excluded_node_ids,
45 _ENTROPY_DEFAULT_THRESHOLD,
46 _DOCSTRING_ENTROPY_DEFAULT,
47 _COMMENT_ENTROPY_DEFAULT,
48 check_no_hardcoded_secrets,
49)
50
51
52# -------------------------------------------------------------------
53# Shannon entropy unit tests
54# -------------------------------------------------------------------
55
57
59 assert _shannon_entropy("") == 0.0
60
62 assert _shannon_entropy("aaaa") == 0.0
63 assert _shannon_entropy("bbbbbbbbb") == 0.0
64
66 result = _shannon_entropy("ab")
67 assert abs(result - 1.0) < 1e-9
68
70 result = _shannon_entropy("abcd")
71 assert abs(result - 2.0) < 1e-9
72
74 entropy = _shannon_entropy(_B64_TEST_STR)
75 assert entropy > 4.0
76 assert entropy == 5.0 # 32 unique chars → log2(32)
77
79 prose = "the quick brown fox jumps"
80 entropy = _shannon_entropy(prose)
81 assert entropy < 4.5
82
84 small = _shannon_entropy("aabbccdd")
85 large = _shannon_entropy("abcdefgh")
86 assert large > small
87
88
89# -------------------------------------------------------------------
90# AST-level violation detection
91# -------------------------------------------------------------------
92
94
95 def _parse(self, code: str) -> ast.Module:
96 return ast.parse(textwrap.dedent(code))
97
99 code = f'x = "{_B64_TEST_STR}"\n'
100 tree = ast.parse(code)
101 violations = _check_entropy_violations(tree, threshold=4.0)
102 assert len(violations) == 1
103 lineno, preview, entropy = violations[0]
104 assert entropy == 5.0
105
107 code = '''
108 x = "short"
109 '''
110 tree = self._parse(code)
111 violations = _check_entropy_violations(tree, threshold=1.0, min_length=16)
112 assert len(violations) == 0
113
115 code = '''
116 msg = "This is a normal message for the user interface"
117 '''
118 tree = self._parse(code)
119 violations = _check_entropy_violations(tree, threshold=4.5)
120 assert len(violations) == 0
121
123 code = '''
124 x = "abcdefghijklmnopqrstuvwxyz"
125 '''
126 tree = self._parse(code)
127 low_threshold = _check_entropy_violations(tree, threshold=3.0)
128 high_threshold = _check_entropy_violations(tree, threshold=7.0)
129 assert len(low_threshold) >= len(high_threshold)
130
132 long_str = "a" * 10 + "B" * 10 + "c" * 10 + "D" * 10
133 code = f'x = "{long_str}"'
134 tree = ast.parse(code)
135 violations = _check_entropy_violations(tree, threshold=1.0, min_length=16)
136 if violations:
137 _, preview, _ = violations[0]
138 assert len(preview) <= 27 # 24 + "..."
139
140
141# -------------------------------------------------------------------
142# Integration with check_no_hardcoded_secrets
143# -------------------------------------------------------------------
144
146
148 code = f'x = "{_B64_TEST_STR}"\n'
149 passed, msg = check_no_hardcoded_secrets(code)
150 assert not passed
151 assert "high-entropy" in msg
152
154 code = f'x = "{_B64_TEST_STR}"\n'
155 passed, msg = check_no_hardcoded_secrets(code, code_entropy_threshold=0)
156 assert passed
157
159 code = f'api_key = "sk-{_B64_TEST_STR[:20]}"\n'
160 passed, msg = check_no_hardcoded_secrets(code, code_entropy_threshold=4.0)
161 assert not passed
162 assert "api_key" in msg
163 assert "high-entropy" in msg
164
165 def test_json_output_includes_entropy(self, tmp_path, capsys):
166 src = tmp_path / "test_project"
167 src.mkdir()
168 test_file = src / "example.py"
169 test_file.write_text(textwrap.dedent(f'''\
170 #!/usr/bin/env python3
171 # -*- coding: utf-8 -*-
172 # example.py
173
174 x = "{_B64_TEST_STR}"
175 '''), encoding="utf-8")
176
177 from oct.linter.oct_lint import run_linter
178 run_linter(src, ["--json", str(test_file)])
179 captured = capsys.readouterr()
180 data = json.loads(captured.out)
181 file_result = data["files"][0]
182 secrets_check = file_result["checks"]["no_hardcoded_secrets"]
183 assert not secrets_check["pass"]
184 assert "high-entropy" in secrets_check["message"]
185
187 assert _ENTROPY_DEFAULT_THRESHOLD == 4.5
188
190 code = textwrap.dedent('''\
191 x = "abcdefghijklmnop"
192 ''')
193 entropy_val = _shannon_entropy("abcdefghijklmnop")
194 strict = check_no_hardcoded_secrets(code, code_entropy_threshold=entropy_val - 0.1)
195 lenient = check_no_hardcoded_secrets(code, code_entropy_threshold=entropy_val + 0.5)
196 assert not strict[0]
197 assert lenient[0]
198
199
200# -------------------------------------------------------------------
201# Per-context entropy thresholds (docstring vs code string)
202# -------------------------------------------------------------------
203
204# 32 unique chars → entropy = 5.0 bits/char (test fixture for base64-like strings)
205_B64_TEST_STR = "aB3dE7fG9hJ2kL5mN8pQ1rS4tU6vW0xY"
206# 28 unique chars → entropy ≈ 4.807 bits/char (above 4.5, below 5.0)
207_MID_ENTROPY_STR = "abcdefghijklmnopqrstuvwxyz01"
208# 38 unique chars → entropy ≈ 5.248 bits/char (above 5.0)
209_HIGH_ENTROPY_STR = "abcdefghijklmnopqrstuvwxyz0123456789AB"
210
211
213 """Context-aware entropy scanning: docstrings use strict >, code uses >=."""
214
215 def _parse(self, code: str) -> ast.Module:
216 return ast.parse(textwrap.dedent(code))
217
219 """Module docstring at ~4.8 bits/char passes default 5.0 threshold."""
220 code = f'"""{_MID_ENTROPY_STR}"""\nx = 1\n'
221 tree = ast.parse(code)
222 violations = _check_entropy_violations(tree, threshold=4.5, docstring_threshold=5.0)
223 assert len(violations) == 0
224
226 """Module docstring at ~5.2 bits/char is caught (strict >)."""
227 code = f'"""{_HIGH_ENTROPY_STR}"""\nx = 1\n'
228 tree = ast.parse(code)
229 violations = _check_entropy_violations(tree, threshold=4.5, docstring_threshold=5.0)
230 assert len(violations) == 1
231
233 """Function docstring at ~4.8 bits/char passes default 5.0 threshold."""
234 code = textwrap.dedent(f'''\
235 def example():
236 """{_MID_ENTROPY_STR}"""
237 return 42
238 ''')
239 tree = ast.parse(code)
240 violations = _check_entropy_violations(tree, threshold=4.5, docstring_threshold=5.0)
241 assert len(violations) == 0
242
244 """Class docstring at ~4.8 bits/char passes default 5.0 threshold."""
245 code = textwrap.dedent(f'''\
246 class Example:
247 """{_MID_ENTROPY_STR}"""
248 pass
249 ''')
250 tree = ast.parse(code)
251 violations = _check_entropy_violations(tree, threshold=4.5, docstring_threshold=5.0)
252 assert len(violations) == 0
253
255 """Regular string at ~4.8 bits/char IS caught at code threshold 4.5."""
256 code = f'x = "{_MID_ENTROPY_STR}"\n'
257 tree = ast.parse(code)
258 violations = _check_entropy_violations(tree, threshold=4.5, docstring_threshold=5.0)
259 assert len(violations) == 1
260
262 """Explicit docstring_entropy_threshold=4.0 catches docstrings above 4.0."""
263 code = f'"""{_MID_ENTROPY_STR}"""\nx = 1\n'
264 tree = ast.parse(code)
265 # ~4.8 exceeds strict > 4.0
266 violations = _check_entropy_violations(tree, threshold=4.5, docstring_threshold=4.0)
267 assert len(violations) == 1
268
270 """Code string at exactly its threshold IS caught (>= semantics)."""
271 test_str = _MID_ENTROPY_STR
272 exact_entropy = _shannon_entropy(test_str)
273 code = f'x = "{test_str}"\n'
274 tree = ast.parse(code)
275 # Set threshold to exact entropy — >= should catch it
276 violations = _check_entropy_violations(tree, threshold=exact_entropy, docstring_threshold=9.0)
277 assert len(violations) == 1
278
280 """Docstring at exactly its threshold is NOT caught (strict > semantics)."""
281 test_str = _MID_ENTROPY_STR
282 exact_entropy = _shannon_entropy(test_str)
283 code = f'"""{test_str}"""\nx = 1\n'
284 tree = ast.parse(code)
285 # Set docstring_threshold to exact entropy — strict > should NOT catch it
286 violations = _check_entropy_violations(tree, threshold=4.5, docstring_threshold=exact_entropy)
287 assert len(violations) == 0
288
290 """comment_threshold parameter is accepted without error."""
291 code = f'x = "{_MID_ENTROPY_STR}"\n'
292 tree = ast.parse(code)
293 # Should not raise, and should still catch the code string
294 violations = _check_entropy_violations(
295 tree, threshold=4.5, docstring_threshold=5.0, comment_threshold=3.0,
296 )
297 assert len(violations) == 1
298
299
300# -------------------------------------------------------------------
301# OI-540: Field-name exclusions (keyword arguments in Call nodes)
302# -------------------------------------------------------------------
303
305
307 """Keyword arg matching a field pattern is excluded from entropy."""
308 code = f'RuleInfo(correct_pattern="{_B64_TEST_STR}")\n'
309 tree = ast.parse(code)
310 excluded = _collect_keyword_excluded_node_ids(tree, ["correct_pattern"])
311 violations = _check_entropy_violations(
312 tree, threshold=4.0, excluded_node_ids=frozenset(excluded),
313 )
314 assert len(violations) == 0
315
317 """Keyword arg NOT in patterns is still flagged."""
318 code = f'RuleInfo(name="{_B64_TEST_STR}")\n'
319 tree = ast.parse(code)
320 excluded = _collect_keyword_excluded_node_ids(tree, ["correct_pattern"])
321 violations = _check_entropy_violations(
322 tree, threshold=4.0, excluded_node_ids=frozenset(excluded),
323 )
324 assert len(violations) == 1
325
327 """Regular assignment to excluded name IS still flagged."""
328 code = f'correct_pattern = "{_B64_TEST_STR}"\n'
329 tree = ast.parse(code)
330 excluded = _collect_keyword_excluded_node_ids(tree, ["correct_pattern"])
331 violations = _check_entropy_violations(
332 tree, threshold=4.0, excluded_node_ids=frozenset(excluded),
333 )
334 assert len(violations) == 1
335
337 """Empty pattern list produces empty excluded set."""
338 code = f'RuleInfo(correct_pattern="{_B64_TEST_STR}")\n'
339 tree = ast.parse(code)
340 excluded = _collect_keyword_excluded_node_ids(tree, [])
341 assert len(excluded) == 0
342
344 """check_no_hardcoded_secrets respects excluded_field_patterns."""
345 code = f'RuleInfo(correct_pattern="{_B64_TEST_STR}")\n'
346 passed, msg = check_no_hardcoded_secrets(
347 code, excluded_field_patterns=["correct_pattern"],
348 )
349 assert passed
350
352 """Without excluded_field_patterns the same code is flagged."""
353 code = f'RuleInfo(correct_pattern="{_B64_TEST_STR}")\n'
354 passed, msg = check_no_hardcoded_secrets(code)
355 assert not passed
356 assert "high-entropy" in msg
357
358
359# -------------------------------------------------------------------
360# OI-540: octrc validation for entropy_exclusions
361# -------------------------------------------------------------------
362
364
366 """Well-formed entropy_exclusions pass validation."""
367 from oct.linter.oct_lint import _validate_octrc_schema
368 cfg = {
369 "secret_scanner": {
370 "entropy_exclusions": [
371 {
372 "field_name_pattern": "correct_pattern",
373 "context": "namedtuple_field",
374 },
375 {
376 "path_pattern": "tests/",
377 "context": "test_directory",
378 "restrictions": {"exempt_entropy_only": True},
379 },
380 ]
381 }
382 }
383 errors = _validate_octrc_schema(cfg)
384 assert len(errors) == 0
385
387 """Invalid context value is caught."""
388 from oct.linter.oct_lint import _validate_octrc_schema
389 cfg = {
390 "secret_scanner": {
391 "entropy_exclusions": [
392 {"context": "unknown_type", "field_name_pattern": "x"},
393 ]
394 }
395 }
396 errors = _validate_octrc_schema(cfg)
397 assert any("context" in e for e in errors)
398
400 """namedtuple_field without field_name_pattern is caught."""
401 from oct.linter.oct_lint import _validate_octrc_schema
402 cfg = {
403 "secret_scanner": {
404 "entropy_exclusions": [
405 {"context": "namedtuple_field"},
406 ]
407 }
408 }
409 errors = _validate_octrc_schema(cfg)
410 assert any("field_name_pattern" in e for e in errors)
411
413 """test_directory without path_pattern is caught."""
414 from oct.linter.oct_lint import _validate_octrc_schema
415 cfg = {
416 "secret_scanner": {
417 "entropy_exclusions": [
418 {"context": "test_directory"},
419 ]
420 }
421 }
422 errors = _validate_octrc_schema(cfg)
423 assert any("path_pattern" in e for e in errors)
424
426 """entropy_exclusions must be an array."""
427 from oct.linter.oct_lint import _validate_octrc_schema
428 cfg = {
429 "secret_scanner": {
430 "entropy_exclusions": "not_a_list",
431 }
432 }
433 errors = _validate_octrc_schema(cfg)
434 assert any("must be an array" in e for e in errors)
435
436
437# -------------------------------------------------------------------
438# OI-540: Shared waiver module
439# -------------------------------------------------------------------
440
442
444 """parse_inline_waivers is importable from oct.core.waivers."""
445 from oct.core.waivers import parse_inline_waivers as fn
446 assert callable(fn)
447
449 """Shared parse_inline_waivers returns correct structure."""
450 from oct.core.waivers import parse_inline_waivers
451 text = 'x = 1 # OCT-LINT: disable=no_hardcoded_secrets reason="test fixture"'
452 waivers = parse_inline_waivers(text)
453 assert 1 in waivers
454 assert waivers[1]["rule"] == "no_hardcoded_secrets"
455 assert waivers[1]["reason"] is not None