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
60 changes: 55 additions & 5 deletions guarddog/analyzer/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,17 +135,67 @@ def _is_in_multiline_comment(

elif language == LANGUAGE.PYTHON:
# For Python, check if we're inside """ or '''
for quote in ['"""', "'''"]:
# Count occurrences - if odd, we're inside a docstring
count = window.count(quote)
if count % 2 == 1:
return True
return Analyzer._ends_inside_python_triple_quote(window)

except Exception:
pass

return False

@staticmethod
def _ends_inside_python_triple_quote(window: str) -> bool:
"""
Return True if the end of `window` falls inside an unterminated Python
triple-quoted string.

A single forward pass is used rather than counting `\"\"\"`/`'''`
occurrences: a triple quote appearing as an ordinary string *value*
(``banner = '\"\"\"'``) or inside a `#` comment must not flip the state,
which parity counting got wrong and which caused real YARA matches to be
discarded as "inside a docstring".
"""
i = 0
n = len(window)
delimiter: Optional[str] = None # currently open quote, if any
while i < n:
char = window[i]
if delimiter is None:
if char == "#":
# Skip to end of line comment
newline = window.find("\n", i)
if newline == -1:
return False
i = newline + 1
continue
if char in "\"'":
if window.startswith(char * 3, i):
delimiter = char * 3
i += 3
else:
delimiter = char
i += 1
continue
i += 1
continue

# Inside a string literal
if char == "\\":
i += 2
continue
if len(delimiter) == 1:
if char == delimiter or char == "\n":
# Single-quoted strings cannot span lines
delimiter = None
i += 1
continue
if window.startswith(delimiter, i):
delimiter = None
i += 3
continue
i += 1

return delimiter is not None and len(delimiter) == 3

@staticmethod
def is_match_in_comment(
file_path: str,
Expand Down
33 changes: 33 additions & 0 deletions tests/core/test_sourcecode_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,39 @@ def test_is_in_multiline_comment_python_triple_single_quotes():
os.unlink(f.name)


def test_is_in_multiline_comment_python_triple_quote_string_value():
"""A triple quote used as a string value must not be read as a docstring."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write("banner = '\"\"\"'\n")
f.write("import os\n")
f.write("os.system('curl evil')\n")
f.flush()

# Byte offset at "os.system(...)", which is executable code
byte_offset = len("banner = '\"\"\"'\nimport os\n".encode())

try:
assert Analyzer._is_in_multiline_comment(f.name, LANGUAGE.PYTHON, byte_offset=byte_offset) is False
finally:
os.unlink(f.name)


def test_is_in_multiline_comment_python_triple_quote_in_hash_comment():
"""A triple quote inside a `#` comment must not be read as a docstring."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write('# see the \"\"\" delimiter\n')
f.write("import os\n")
f.write("os.system('curl evil')\n")
f.flush()

byte_offset = len('# see the \"\"\" delimiter\nimport os\n'.encode())

try:
assert Analyzer._is_in_multiline_comment(f.name, LANGUAGE.PYTHON, byte_offset=byte_offset) is False
finally:
os.unlink(f.name)


@pytest.mark.parametrize(
"suffix,comment_marker,code_line",
[
Expand Down
Loading