Skip to content

Commit 097980b

Browse files
authored
CM 59844: update deps (#390)
1 parent d956fdb commit 097980b

File tree

7 files changed

+1140
-20
lines changed

7 files changed

+1140
-20
lines changed

poetry.lock

Lines changed: 210 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ version = "0.0.0" # DON'T TOUCH. Placeholder. Will be filled automatically on po
3636
click = ">=8.1.0,<8.2.0"
3737
colorama = ">=0.4.3,<0.5.0"
3838
pyyaml = ">=6.0,<7.0"
39-
marshmallow = ">=3.15.0,<3.23.0" # 3.23 dropped support for Python 3.8
39+
marshmallow = ">=3.15.0,<4.0.0"
4040
gitpython = ">=3.1.30,<3.2.0"
4141
arrow = ">=1.0.0,<1.4.0"
4242
binaryornot = ">=0.4.4,<0.5.0"
4343
requests = ">=2.32.4,<3.0"
44-
urllib3 = "1.26.19" # lock v1 to avoid issues with openssl and old Python versions (<3.9.11) on macOS
44+
urllib3 = ">=2.4.0,<3.0.0"
4545
pyjwt = ">=2.8.0,<3.0"
4646
rich = ">=13.9.4, <14"
4747
patch-ng = "1.18.1"

tests/cli/apps/__init__.py

Whitespace-only changes.

tests/cli/apps/mcp/__init__.py

Whitespace-only changes.
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import json
2+
import os
3+
import sys
4+
from unittest.mock import AsyncMock, patch
5+
6+
import pytest
7+
8+
if sys.version_info < (3, 10):
9+
pytest.skip('MCP requires Python 3.10+', allow_module_level=True)
10+
11+
from cycode.cli.apps.mcp.mcp_command import (
12+
_sanitize_file_path,
13+
_TempFilesManager,
14+
)
15+
16+
pytestmark = pytest.mark.anyio
17+
18+
19+
@pytest.fixture
20+
def anyio_backend() -> str:
21+
return 'asyncio'
22+
23+
24+
# --- _sanitize_file_path input validation ---
25+
26+
27+
def test_sanitize_file_path_rejects_empty_string() -> None:
28+
with pytest.raises(ValueError, match='non-empty string'):
29+
_sanitize_file_path('')
30+
31+
32+
def test_sanitize_file_path_rejects_none() -> None:
33+
with pytest.raises(ValueError, match='non-empty string'):
34+
_sanitize_file_path(None)
35+
36+
37+
def test_sanitize_file_path_rejects_non_string() -> None:
38+
with pytest.raises(ValueError, match='non-empty string'):
39+
_sanitize_file_path(123)
40+
41+
42+
def test_sanitize_file_path_strips_null_bytes() -> None:
43+
result = _sanitize_file_path('foo/bar\x00baz.py')
44+
assert '\x00' not in result
45+
46+
47+
def test_sanitize_file_path_passes_valid_path_through() -> None:
48+
result = _sanitize_file_path('src/main.py')
49+
assert os.path.normpath(result) == os.path.normpath('src/main.py')
50+
51+
52+
# --- _TempFilesManager: path traversal prevention ---
53+
#
54+
# _sanitize_file_path delegates to pathvalidate which does NOT block
55+
# path traversal (../ passes through). The real security boundary is
56+
# the normpath containment check in _TempFilesManager.__enter__ (lines 136-139).
57+
# These tests verify that the two layers together prevent escaping the temp dir.
58+
59+
60+
def test_traversal_simple_dotdot_rejected() -> None:
61+
"""../../../etc/passwd must not escape the temp directory."""
62+
files = {
63+
'../../../etc/passwd': 'malicious',
64+
'safe.py': 'ok',
65+
}
66+
with _TempFilesManager(files, 'test-traversal') as temp_files:
67+
assert len(temp_files) == 1
68+
assert temp_files[0].endswith('safe.py')
69+
for tf in temp_files:
70+
assert '/etc/passwd' not in tf
71+
72+
73+
def test_traversal_backslash_dotdot_rejected() -> None:
74+
"""..\\..\\windows\\system32 must not escape the temp directory."""
75+
files = {
76+
'..\\..\\windows\\system32\\config': 'malicious',
77+
'safe.py': 'ok',
78+
}
79+
with _TempFilesManager(files, 'test-backslash') as temp_files:
80+
assert len(temp_files) == 1
81+
assert temp_files[0].endswith('safe.py')
82+
83+
84+
def test_traversal_embedded_dotdot_rejected() -> None:
85+
"""foo/../../../etc/passwd resolves outside temp dir and must be rejected."""
86+
files = {
87+
'foo/../../../etc/passwd': 'malicious',
88+
'safe.py': 'ok',
89+
}
90+
with _TempFilesManager(files, 'test-embedded') as temp_files:
91+
assert len(temp_files) == 1
92+
assert temp_files[0].endswith('safe.py')
93+
94+
95+
def test_traversal_absolute_path_rejected() -> None:
96+
"""Absolute paths must not be written outside the temp directory."""
97+
files = {
98+
'/etc/passwd': 'malicious',
99+
'safe.py': 'ok',
100+
}
101+
with _TempFilesManager(files, 'test-absolute') as temp_files:
102+
assert len(temp_files) == 1
103+
assert temp_files[0].endswith('safe.py')
104+
105+
106+
def test_traversal_dotdot_only_rejected() -> None:
107+
"""A bare '..' path must be rejected."""
108+
files = {
109+
'..': 'malicious',
110+
'safe.py': 'ok',
111+
}
112+
with _TempFilesManager(files, 'test-bare-dotdot') as temp_files:
113+
assert len(temp_files) == 1
114+
115+
116+
def test_traversal_all_malicious_raises() -> None:
117+
"""If every file path is a traversal attempt, no files are created and ValueError is raised."""
118+
files = {
119+
'../../../etc/passwd': 'malicious',
120+
'../../shadow': 'also malicious',
121+
}
122+
with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-all-malicious'):
123+
pass
124+
125+
126+
def test_all_created_files_are_inside_temp_dir() -> None:
127+
"""Every created file must be under the temp base directory."""
128+
files = {
129+
'a.py': 'aaa',
130+
'sub/b.py': 'bbb',
131+
'sub/deep/c.py': 'ccc',
132+
}
133+
manager = _TempFilesManager(files, 'test-containment')
134+
with manager as temp_files:
135+
base = os.path.normcase(os.path.normpath(manager.temp_base_dir))
136+
for tf in temp_files:
137+
normalized = os.path.normcase(os.path.normpath(tf))
138+
assert normalized.startswith(base + os.sep), f'{tf} escaped temp dir {base}'
139+
140+
141+
def test_mixed_valid_and_traversal_only_creates_valid() -> None:
142+
"""Valid files are created, traversal attempts are silently skipped."""
143+
files = {
144+
'../escape.py': 'bad',
145+
'legit.py': 'good',
146+
'foo/../../escape2.py': 'bad',
147+
'src/app.py': 'good',
148+
}
149+
manager = _TempFilesManager(files, 'test-mixed')
150+
with manager as temp_files:
151+
base = os.path.normcase(os.path.normpath(manager.temp_base_dir))
152+
assert len(temp_files) == 2
153+
for tf in temp_files:
154+
assert os.path.normcase(os.path.normpath(tf)).startswith(base + os.sep)
155+
basenames = [os.path.basename(tf) for tf in temp_files]
156+
assert 'legit.py' in basenames
157+
assert 'app.py' in basenames
158+
159+
160+
# --- _TempFilesManager: general functionality ---
161+
162+
163+
def test_temp_files_manager_creates_files() -> None:
164+
files = {
165+
'test1.py': 'print("hello")',
166+
'subdir/test2.js': 'console.log("world")',
167+
}
168+
with _TempFilesManager(files, 'test-call-id') as temp_files:
169+
assert len(temp_files) == 2
170+
for tf in temp_files:
171+
assert os.path.exists(tf)
172+
173+
174+
def test_temp_files_manager_writes_correct_content() -> None:
175+
files = {'hello.py': 'print("hello world")'}
176+
with _TempFilesManager(files, 'test-content') as temp_files, open(temp_files[0]) as f:
177+
assert f.read() == 'print("hello world")'
178+
179+
180+
def test_temp_files_manager_cleans_up_on_exit() -> None:
181+
files = {'cleanup.py': 'code'}
182+
manager = _TempFilesManager(files, 'test-cleanup')
183+
with manager as temp_files:
184+
temp_dir = manager.temp_base_dir
185+
assert os.path.exists(temp_dir)
186+
assert len(temp_files) == 1
187+
assert not os.path.exists(temp_dir)
188+
189+
190+
def test_temp_files_manager_empty_path_raises() -> None:
191+
files = {'': 'empty path'}
192+
with pytest.raises(ValueError, match='No valid files'), _TempFilesManager(files, 'test-empty-path'):
193+
pass
194+
195+
196+
def test_temp_files_manager_preserves_subdirectory_structure() -> None:
197+
files = {
198+
'src/main.py': 'main',
199+
'src/utils/helper.py': 'helper',
200+
}
201+
with _TempFilesManager(files, 'test-dirs') as temp_files:
202+
assert len(temp_files) == 2
203+
paths = [os.path.basename(tf) for tf in temp_files]
204+
assert 'main.py' in paths
205+
assert 'helper.py' in paths
206+
207+
208+
# --- _run_cycode_command (async) ---
209+
210+
211+
@pytest.mark.anyio
212+
async def test_run_cycode_command_returns_dict() -> None:
213+
from cycode.cli.apps.mcp.mcp_command import _run_cycode_command
214+
215+
mock_process = AsyncMock()
216+
mock_process.communicate.return_value = (b'', b'error output')
217+
mock_process.returncode = 1
218+
219+
with patch('asyncio.create_subprocess_exec', return_value=mock_process):
220+
result = await _run_cycode_command('--invalid-flag-for-test')
221+
assert isinstance(result, dict)
222+
assert 'error' in result
223+
224+
225+
@pytest.mark.anyio
226+
async def test_run_cycode_command_parses_json_output() -> None:
227+
from cycode.cli.apps.mcp.mcp_command import _run_cycode_command
228+
229+
mock_process = AsyncMock()
230+
mock_process.communicate.return_value = (b'{"status": "ok"}', b'')
231+
mock_process.returncode = 0
232+
233+
with patch('asyncio.create_subprocess_exec', return_value=mock_process):
234+
result = await _run_cycode_command('version')
235+
assert result == {'status': 'ok'}
236+
237+
238+
@pytest.mark.anyio
239+
async def test_run_cycode_command_handles_invalid_json() -> None:
240+
from cycode.cli.apps.mcp.mcp_command import _run_cycode_command
241+
242+
mock_process = AsyncMock()
243+
mock_process.communicate.return_value = (b'not json{', b'')
244+
mock_process.returncode = 0
245+
246+
with patch('asyncio.create_subprocess_exec', return_value=mock_process):
247+
result = await _run_cycode_command('version')
248+
assert result['error'] == 'Failed to parse JSON output'
249+
250+
251+
@pytest.mark.anyio
252+
async def test_run_cycode_command_timeout() -> None:
253+
import asyncio
254+
255+
from cycode.cli.apps.mcp.mcp_command import _run_cycode_command
256+
257+
async def slow_communicate() -> tuple[bytes, bytes]:
258+
await asyncio.sleep(10)
259+
return b'', b''
260+
261+
mock_process = AsyncMock()
262+
mock_process.communicate = slow_communicate
263+
264+
with patch('asyncio.create_subprocess_exec', return_value=mock_process):
265+
result = await _run_cycode_command('status', timeout=0.001)
266+
assert isinstance(result, dict)
267+
assert 'error' in result
268+
assert 'timeout' in result['error'].lower()
269+
270+
271+
# --- _cycode_scan_tool ---
272+
273+
274+
@pytest.mark.anyio
275+
async def test_cycode_scan_tool_no_files() -> None:
276+
from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool
277+
from cycode.cli.cli_types import ScanTypeOption
278+
279+
result = await _cycode_scan_tool(ScanTypeOption.SECRET, {})
280+
parsed = json.loads(result)
281+
assert 'error' in parsed
282+
assert 'No files provided' in parsed['error']
283+
284+
285+
@pytest.mark.anyio
286+
async def test_cycode_scan_tool_invalid_files() -> None:
287+
from cycode.cli.apps.mcp.mcp_command import _cycode_scan_tool
288+
from cycode.cli.cli_types import ScanTypeOption
289+
290+
result = await _cycode_scan_tool(ScanTypeOption.SECRET, {'': 'content'})
291+
parsed = json.loads(result)
292+
assert 'error' in parsed
293+
294+
295+
# --- _create_mcp_server ---
296+
297+
298+
def test_create_mcp_server() -> None:
299+
from cycode.cli.apps.mcp.mcp_command import _create_mcp_server
300+
301+
server = _create_mcp_server('127.0.0.1', 8000)
302+
assert server is not None
303+
assert server.name == 'cycode'
304+
305+
306+
def test_create_mcp_server_registers_tools() -> None:
307+
from cycode.cli.apps.mcp.mcp_command import _create_mcp_server
308+
309+
server = _create_mcp_server('127.0.0.1', 8000)
310+
tool_names = [t.name for t in server._tool_manager._tools.values()]
311+
assert 'cycode_status' in tool_names
312+
assert 'cycode_secret_scan' in tool_names
313+
assert 'cycode_sca_scan' in tool_names
314+
assert 'cycode_iac_scan' in tool_names
315+
assert 'cycode_sast_scan' in tool_names

0 commit comments

Comments
 (0)