From 9cc9cc053cda4218be40b1367f2473619a5d524e Mon Sep 17 00:00:00 2001 From: Syed Farhan Ahmed Date: Fri, 17 Jul 2026 17:18:17 +0500 Subject: [PATCH] fix(constant-time-analysis): restore dis offset column on Python 3.13+ Python 3.13 stopped printing the bytecode byte-offset column in `python -m dis` output by default (see What's New in Python 3.13), adding `-O`/`--show-offsets` to opt back in. PythonAnalyzer's _parse_dis_output regex requires that offset column, so on 3.13+ every instruction line silently failed to match: total_instructions stayed at 0 and the analyzer reported "PASSED, no violations" on code with genuine timing side-channels (e.g. an early-exit byte-by-byte comparison on secret data). _get_dis_output now detects --show-offsets support at runtime and passes it only when available, so it keeps working unmodified on Python < 3.13 where the flag doesn't exist. Verified against the bundled tests/test_samples/vulnerable.py sample: before this change it reported 0 instructions analyzed and "PASSED"; after, it correctly reports 328 instructions and multiple violations. Added a regression test that would have caught this. --- .../ct_analyzer/script_analyzers.py | 40 ++++++++++++++--- .../ct_analyzer/tests/test_analyzer.py | 43 +++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/plugins/constant-time-analysis/ct_analyzer/script_analyzers.py b/plugins/constant-time-analysis/ct_analyzer/script_analyzers.py index a8c5295a..fdb51a40 100644 --- a/plugins/constant-time-analysis/ct_analyzer/script_analyzers.py +++ b/plugins/constant-time-analysis/ct_analyzer/script_analyzers.py @@ -1335,6 +1335,7 @@ class PythonAnalyzer(ScriptAnalyzer): def __init__(self, python_path: str | None = None): self.python_path = python_path or "python3" + self._show_offsets_supported: bool | None = None def is_available(self) -> bool: """Check if Python is available.""" @@ -1348,14 +1349,41 @@ def is_available(self) -> bool: except FileNotFoundError: return False + def _supports_show_offsets(self) -> bool: + """ + Check if this Python's `python -m dis` CLI supports --show-offsets. + + Python 3.13 stopped printing the bytecode byte-offset column by + default (see https://docs.python.org/3/whatsnew/3.13.html) and added + `-O`/`--show-offsets` to opt back in. `_parse_dis_output` below + depends on that offset column being present, so on 3.13+ we must + pass this flag explicitly or every instruction line silently fails + to match and no violations are ever reported (a false negative, + not a "no violations found" true negative). Older Pythons (<3.13) + printed offsets by default and don't have this flag at all, so we + detect support at runtime instead of hardcoding a version check. + """ + if self._show_offsets_supported is not None: + return self._show_offsets_supported + + try: + result = subprocess.run( + [self.python_path, "-m", "dis", "--help"], + capture_output=True, + text=True, + ) + self._show_offsets_supported = "--show-offsets" in result.stdout + except FileNotFoundError: + self._show_offsets_supported = False + + return self._show_offsets_supported + def _get_dis_output(self, source_file: str) -> tuple[bool, str]: """Get Python dis module output for bytecode disassembly.""" - cmd = [ - self.python_path, - "-m", - "dis", - source_file, - ] + cmd = [self.python_path, "-m", "dis"] + if self._supports_show_offsets(): + cmd.append("--show-offsets") + cmd.append(source_file) try: result = subprocess.run(cmd, capture_output=True, text=True) diff --git a/plugins/constant-time-analysis/ct_analyzer/tests/test_analyzer.py b/plugins/constant-time-analysis/ct_analyzer/tests/test_analyzer.py index bee84603..835507a2 100644 --- a/plugins/constant-time-analysis/ct_analyzer/tests/test_analyzer.py +++ b/plugins/constant-time-analysis/ct_analyzer/tests/test_analyzer.py @@ -725,6 +725,49 @@ def test_parse_dis_division_python310(self): self.assertIn("BINARY_TRUE_DIVIDE", mnemonics) self.assertIn("BINARY_MODULO", mnemonics) + def test_get_dis_output_includes_offsets_on_py313_plus(self): + """ + Regression test for a silent false negative on Python 3.13+. + + Python 3.13's `python -m dis` stopped printing the instruction + byte-offset column by default and added `-O`/`--show-offsets` to + opt back in. `_parse_dis_output`'s regex requires that column to + be present; without it every instruction line silently fails to + match, `functions[*]["instructions"]` stays 0, and no violations + are ever reported -- the analyzer reports "PASSED" on vulnerable + code instead of erroring or warning. + + `_get_dis_output` must detect `--show-offsets` support at runtime + and pass it when available, while still working unmodified on + Python < 3.13 where the flag doesn't exist. + """ + import tempfile + + from script_analyzers import PythonAnalyzer + + analyzer = PythonAnalyzer() + if not analyzer._supports_show_offsets(): + self.skipTest("This Python's dis module predates --show-offsets (< 3.13)") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write("def divide(a, b):\n return a / b\n") + temp_path = f.name + + try: + success, output = analyzer._get_dis_output(temp_path) + self.assertTrue(success, f"dis invocation failed: {output}") + + functions, _violations = analyzer._parse_dis_output(output, temp_path) + total_instructions = sum(f["instructions"] for f in functions) + self.assertGreater( + total_instructions, + 0, + "dis output is missing the offset column the parser depends on -- " + "every instruction line silently failed to parse", + ) + finally: + os.unlink(temp_path) + def test_detect_random_in_source(self): """Should detect random.random() calls in source.""" import tempfile