Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions plugins/constant-time-analysis/ct_analyzer/script_analyzers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading