Skip to content

Commit c7dcb13

Browse files
ilia-cyclaude
andauthored
CM-67391: Use S3 presigned upload for secret CLI scans (#476)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a4b4957 commit c7dcb13

6 files changed

Lines changed: 187 additions & 8 deletions

File tree

cycode/cli/apps/scan/code_scanner.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,10 @@ def scan_documents(
279279

280280
scan_batch_thread_func = _get_scan_documents_thread_func(ctx, is_git_diff, is_commit_range, scan_parameters)
281281

282-
if should_use_presigned_upload(scan_type):
282+
# Presigned single-file upload is async-only; a --sync scan must stay on the batched inline path
283+
# so it never builds one oversized zip to POST synchronously.
284+
should_use_sync_flow = _should_use_sync_flow(ctx.info_name, scan_type, ctx.obj['sync'])
285+
if should_use_presigned_upload(scan_type) and not should_use_sync_flow:
283286
errors, local_scan_results = _run_presigned_upload_scan(
284287
scan_batch_thread_func, scan_type, documents_to_scan, progress_bar, printer
285288
)
@@ -388,7 +391,11 @@ def _perform_scan(
388391
is_commit_range,
389392
on_upload_progress,
390393
)
391-
except requests.exceptions.RequestException:
394+
except (
395+
requests.exceptions.RequestException,
396+
custom_exceptions.RequestError,
397+
custom_exceptions.SlowUploadConnectionError,
398+
):
392399
logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ')
393400

394401
return _perform_scan_async(

cycode/cli/apps/scan/commit_range_scanner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
print_local_scan_results,
2020
)
2121
from cycode.cli.config import configuration_manager
22+
from cycode.cli.exceptions import custom_exceptions
2223
from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception
2324
from cycode.cli.files_collector.commit_range_documents import (
2425
collect_commit_range_diff_documents,
@@ -162,7 +163,11 @@ def _scan_commit_range_documents(
162163
scan_parameters,
163164
timeout,
164165
)
165-
except requests.exceptions.RequestException:
166+
except (
167+
requests.exceptions.RequestException,
168+
custom_exceptions.RequestError,
169+
custom_exceptions.SlowUploadConnectionError,
170+
):
166171
logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ')
167172
scan_result = _perform_commit_range_scan_async(
168173
cycode_client,

cycode/cli/consts.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,13 @@
222222
FILE_MAX_SIZE_LIMIT_IN_BYTES = 5000000
223223

224224
PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 5 * 1024 * 1024 * 1024 # 5 GB (S3 presigned POST limit)
225-
PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE}
225+
PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE, SECRET_SCAN_TYPE}
226226

227227
DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES = 20 * 1024 * 1024
228228
ZIP_MAX_SIZE_LIMIT_IN_BYTES = {
229229
SCA_SCAN_TYPE: 200 * 1024 * 1024,
230230
SAST_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES,
231+
SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES,
231232
}
232233

233234
# scan in batches

tests/cli/commands/scan/test_code_scanner.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22
from os.path import normpath
33
from unittest.mock import MagicMock, Mock, patch
44

5+
import pytest
6+
57
from cycode.cli import consts
6-
from cycode.cli.apps.scan.code_scanner import scan_disk_files
8+
from cycode.cli.apps.scan.code_scanner import _perform_scan, scan_disk_files, scan_documents
9+
from cycode.cli.exceptions import custom_exceptions
710
from cycode.cli.files_collector.file_excluder import _is_file_relevant_for_sca_scan
811
from cycode.cli.files_collector.path_documents import _generate_document
912
from cycode.cli.models import Document
@@ -162,3 +165,75 @@ def test_entrypoint_cycode_not_added_for_single_file(
162165
assert len(entrypoint_docs) == 0
163166
# Verify only the original documents are present
164167
assert len(documents_passed) == len(mock_documents)
168+
169+
170+
@pytest.mark.parametrize(
171+
('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'),
172+
[
173+
# SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate).
174+
(consts.SAST_SCAN_TYPE, 'path', False, True),
175+
# Async secret scans now upload as a single file directly to S3 via a presigned URL.
176+
(consts.SECRET_SCAN_TYPE, 'path', False, True),
177+
# A --sync secret scan must stay on the batched inline path and never build one giant zip.
178+
(consts.SECRET_SCAN_TYPE, 'path', True, False),
179+
],
180+
)
181+
@patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results')
182+
@patch('cycode.cli.apps.scan.code_scanner.set_issue_detected_by_scan_results')
183+
@patch('cycode.cli.apps.scan.code_scanner.try_set_aggregation_report_url_if_needed')
184+
@patch('cycode.cli.apps.scan.code_scanner.run_parallel_batched_scan')
185+
@patch('cycode.cli.apps.scan.code_scanner._run_presigned_upload_scan')
186+
def test_scan_documents_routes_upload_by_scan_type_and_sync(
187+
mock_presigned_upload: Mock,
188+
mock_batched_scan: Mock,
189+
mock_aggregation: Mock,
190+
mock_set_issue: Mock,
191+
mock_print: Mock,
192+
scan_type: str,
193+
command_scan_type: str,
194+
sync_option: bool,
195+
expect_presigned: bool,
196+
) -> None:
197+
mock_presigned_upload.return_value = ([], [])
198+
mock_batched_scan.return_value = ([], [])
199+
200+
mock_ctx = MagicMock()
201+
mock_ctx.info_name = command_scan_type
202+
mock_ctx.obj = {
203+
'scan_type': scan_type,
204+
'progress_bar': MagicMock(),
205+
'console_printer': MagicMock(),
206+
'client': MagicMock(),
207+
'severity_threshold': None,
208+
'sync': sync_option,
209+
}
210+
documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)]
211+
212+
scan_documents(mock_ctx, documents, {})
213+
214+
assert mock_presigned_upload.called is expect_presigned
215+
assert mock_batched_scan.called is (not expect_presigned)
216+
217+
218+
@patch('cycode.cli.apps.scan.code_scanner._perform_scan_async')
219+
@patch('cycode.cli.apps.scan.code_scanner._perform_scan_v4_async')
220+
def test_perform_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error(
221+
mock_v4_async: Mock, mock_async: Mock
222+
) -> None:
223+
# RequestConnectionError is a CycodeError, not a requests.RequestException — the fallback must still catch it.
224+
mock_v4_async.side_effect = custom_exceptions.RequestConnectionError
225+
fallback_result = object()
226+
mock_async.return_value = fallback_result
227+
228+
result = _perform_scan(
229+
cycode_client=MagicMock(),
230+
zipped_documents=MagicMock(),
231+
scan_type=consts.SAST_SCAN_TYPE,
232+
is_git_diff=False,
233+
is_commit_range=False,
234+
scan_parameters={},
235+
)
236+
237+
assert result is fallback_result
238+
mock_v4_async.assert_called_once()
239+
mock_async.assert_called_once()
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from unittest.mock import MagicMock, Mock, patch
2+
3+
from cycode.cli import consts
4+
from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents
5+
from cycode.cli.exceptions import custom_exceptions
6+
from cycode.cli.models import Document
7+
8+
9+
@patch('cycode.cli.apps.scan.commit_range_scanner.report_scan_status')
10+
@patch('cycode.cli.apps.scan.commit_range_scanner.handle_scan_exception')
11+
@patch('cycode.cli.apps.scan.commit_range_scanner.print_local_scan_results')
12+
@patch('cycode.cli.apps.scan.commit_range_scanner.set_issue_detected_by_scan_results')
13+
@patch('cycode.cli.apps.scan.commit_range_scanner.create_local_scan_result')
14+
@patch('cycode.cli.apps.scan.commit_range_scanner.enrich_scan_result_with_data_from_detection_rules')
15+
@patch('cycode.cli.apps.scan.commit_range_scanner.zip_documents')
16+
@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_async')
17+
@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_v4_async')
18+
def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error(
19+
mock_v4_async: Mock,
20+
mock_async: Mock,
21+
mock_zip: Mock,
22+
mock_enrich: Mock,
23+
mock_create_result: Mock,
24+
mock_set_issue: Mock,
25+
mock_print: Mock,
26+
mock_handle_exception: Mock,
27+
mock_report_status: Mock,
28+
) -> None:
29+
# SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned
30+
# commit-range fallback must still catch it and retry via the Cycode API.
31+
mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError
32+
fallback_result = MagicMock()
33+
mock_async.return_value = fallback_result
34+
35+
mock_ctx = MagicMock()
36+
mock_ctx.info_name = 'commit_history'
37+
mock_ctx.obj = {
38+
'client': MagicMock(),
39+
'scan_type': consts.SECRET_SCAN_TYPE,
40+
'severity_threshold': None,
41+
'progress_bar': MagicMock(),
42+
}
43+
documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)]
44+
45+
_scan_commit_range_documents(mock_ctx, documents, [])
46+
47+
mock_v4_async.assert_called_once()
48+
mock_async.assert_called_once()
49+
mock_handle_exception.assert_not_called()

tests/cyclient/mocked_responses/scan_client.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import responses
77

8+
from cycode.cli.utils.scan_utils import should_use_presigned_upload
89
from cycode.cyclient.scan_client import ScanClient
910
from tests.conftest import MOCKED_RESPONSES_PATH
1011

@@ -128,6 +129,38 @@ def get_scan_configuration_response(url: str) -> responses.Response:
128129
return responses.Response(method=responses.GET, url=url, json=json_response, status=200)
129130

130131

132+
_PRESIGNED_UPLOAD_URL = 'https://cycode-tests.s3.amazonaws.com/presigned-upload'
133+
134+
135+
def get_upload_link_url(scan_type: str, scan_client: ScanClient) -> str:
136+
api_url = scan_client.scan_cycode_client.api_url
137+
async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type)
138+
service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/upload-link'
139+
return f'{api_url}/{service_url}'
140+
141+
142+
def get_upload_link_response(url: str) -> responses.Response:
143+
json_response = {'upload_id': str(uuid4()), 'url': _PRESIGNED_UPLOAD_URL, 'presigned_post_fields': {}}
144+
return responses.Response(method=responses.GET, url=url, json=json_response, status=200)
145+
146+
147+
def get_presigned_upload_response() -> responses.Response:
148+
return responses.Response(method=responses.POST, url=_PRESIGNED_UPLOAD_URL, status=204)
149+
150+
151+
def get_scan_from_upload_id_url(scan_type: str, scan_client: ScanClient) -> str:
152+
api_url = scan_client.scan_cycode_client.api_url
153+
async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type)
154+
service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/repository'
155+
return f'{api_url}/{service_url}'
156+
157+
158+
def get_scan_from_upload_id_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response:
159+
if not scan_id:
160+
scan_id = uuid4()
161+
return responses.Response(method=responses.POST, url=url, json={'scan_id': str(scan_id)}, status=200)
162+
163+
131164
def mock_remote_config_responses(responses_module: responses, scan_type: str, scan_client: ScanClient) -> None:
132165
responses_module.add(get_scan_configuration_response(get_scan_configuration_url(scan_type, scan_client)))
133166

@@ -136,9 +169,18 @@ def mock_scan_async_responses(
136169
responses_module: responses, scan_type: str, scan_client: ScanClient, scan_id: UUID, zip_content_path: Path
137170
) -> None:
138171
mock_remote_config_responses(responses_module, scan_type, scan_client)
139-
responses_module.add(
140-
get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id)
141-
)
172+
173+
if should_use_presigned_upload(scan_type):
174+
responses_module.add(get_upload_link_response(get_upload_link_url(scan_type, scan_client)))
175+
responses_module.add(get_presigned_upload_response())
176+
responses_module.add(
177+
get_scan_from_upload_id_response(get_scan_from_upload_id_url(scan_type, scan_client), scan_id)
178+
)
179+
else:
180+
responses_module.add(
181+
get_zipped_file_scan_async_response(get_zipped_file_scan_async_url(scan_type, scan_client), scan_id)
182+
)
183+
142184
responses_module.add(get_scan_details_response(get_scan_details_url(scan_type, scan_id, scan_client), scan_id))
143185
responses_module.add(get_detection_rules_response(get_detection_rules_url(scan_client)))
144186
responses_module.add(get_scan_detections_response(get_scan_detections_url(scan_client), scan_id, zip_content_path))

0 commit comments

Comments
 (0)