From dec4f9f833fc6ec300c8b9b358389d208dad929b Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 27 Feb 2026 16:41:03 +0100 Subject: [PATCH] CM-60184-Scans using presigned post url --- cycode/cli/apps/scan/code_scanner.py | 27 +++++++++++++++++++ cycode/cyclient/models.py | 20 ++++++++++++++ cycode/cyclient/scan_client.py | 39 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 3ffefd0f..b2e0d45f 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -217,6 +217,28 @@ def scan_documents( print_local_scan_results(ctx, local_scan_results, errors) +def _perform_scan_v2_async( + cycode_client: 'ScanClient', + zipped_documents: 'InMemoryZip', + scan_type: str, + scan_parameters: dict, + is_git_diff: bool, + is_commit_range: bool, +) -> ZippedFileScanResult: + upload_link = cycode_client.get_upload_link(scan_type) + logger.debug('Got upload link, %s', {'upload_id': upload_link.upload_id}) + + cycode_client.upload_to_presigned_post(upload_link.url, upload_link.fields, zipped_documents) + logger.debug('Uploaded zip to presigned URL') + + scan_async_result = cycode_client.scan_repository_from_upload_id( + scan_type, upload_link.upload_id, scan_parameters, is_git_diff, is_commit_range + ) + logger.debug('V2 scan request triggered, %s', {'scan_id': scan_async_result.scan_id}) + + return poll_scan_results(cycode_client, scan_async_result.scan_id, scan_type, scan_parameters) + + def _perform_scan_async( cycode_client: 'ScanClient', zipped_documents: 'InMemoryZip', @@ -262,6 +284,11 @@ def _perform_scan( # it does not support commit range scans; should_use_sync_flow handles it return _perform_scan_sync(cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff) + if scan_type == consts.SAST_SCAN_TYPE: + return _perform_scan_v2_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range + ) + return _perform_scan_async(cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range) diff --git a/cycode/cyclient/models.py b/cycode/cyclient/models.py index c3144a53..28bf3bbc 100644 --- a/cycode/cyclient/models.py +++ b/cycode/cyclient/models.py @@ -114,6 +114,26 @@ def build_dto(self, data: dict[str, Any], **_) -> 'ScanResult': return ScanResult(**data) +@dataclass +class UploadLinkResponse: + upload_id: str + url: str + fields: dict[str, str] + + +class UploadLinkResponseSchema(Schema): + class Meta: + unknown = EXCLUDE + + upload_id = fields.String() + url = fields.String() + fields = fields.Dict(keys=fields.String(), values=fields.String()) + + @post_load + def build_dto(self, data: dict[str, Any], **_) -> 'UploadLinkResponse': + return UploadLinkResponse(**data) + + class ScanInitializationResponse(Schema): def __init__(self, scan_id: Optional[str] = None, err: Optional[str] = None) -> None: super().__init__() diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 4f2debca..fa64e7bb 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, Optional, Union from uuid import UUID +import requests from requests import Response from cycode.cli import consts @@ -25,6 +26,7 @@ def __init__( self.scan_config = scan_config self._SCAN_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/cli-scan' + self._SCAN_SERVICE_V2_CLI_CONTROLLER_PATH = 'api/v2/cli-scan' self._DETECTIONS_SERVICE_CLI_CONTROLLER_PATH = 'api/v1/detections/cli' self._POLICIES_SERVICE_CONTROLLER_PATH_V3 = 'api/v3/policies' @@ -56,6 +58,10 @@ def get_scan_aggregation_report_url(self, aggregation_id: str, scan_type: str) - ) return models.ScanReportUrlResponseSchema().build_dto(response.json()) + def get_scan_service_v2_url_path(self, scan_type: str) -> str: + service_path = self.scan_config.get_service_name(scan_type) + return f'{service_path}/{self._SCAN_SERVICE_V2_CLI_CONTROLLER_PATH}' + def get_zipped_file_scan_async_url_path(self, scan_type: str, should_use_sync_flow: bool = False) -> str: async_scan_type = self.scan_config.get_async_scan_type(scan_type) async_entity_type = self.scan_config.get_async_entity_type(scan_type) @@ -123,6 +129,39 @@ def zipped_file_scan_async( ) return models.ScanInitializationResponseSchema().load(response.json()) + def get_upload_link(self, scan_type: str) -> models.UploadLinkResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v2_url_path(scan_type)}/{async_scan_type}/upload-link' + response = self.scan_cycode_client.get(url_path=url_path, hide_response_content_log=self._hide_response_log) + return models.UploadLinkResponseSchema().load(response.json()) + + def upload_to_presigned_post(self, url: str, fields: dict[str, str], zip_file: 'InMemoryZip') -> None: + multipart = {key: (None, value) for key, value in fields.items()} + multipart['file'] = (None, zip_file.read()) + response = requests.post(url, files=multipart, timeout=self.scan_cycode_client.timeout) + response.raise_for_status() + + def scan_repository_from_upload_id( + self, + scan_type: str, + upload_id: str, + scan_parameters: dict, + is_git_diff: bool = False, + is_commit_range: bool = False, + ) -> models.ScanInitializationResponse: + async_scan_type = self.scan_config.get_async_scan_type(scan_type) + url_path = f'{self.get_scan_service_v2_url_path(scan_type)}/{async_scan_type}/repository' + response = self.scan_cycode_client.post( + url_path=url_path, + data={ + 'upload_id': upload_id, + 'is_git_diff': is_git_diff, + 'is_commit_range': is_commit_range, + 'scan_parameters': json.dumps(scan_parameters), + }, + ) + return models.ScanInitializationResponseSchema().load(response.json()) + def commit_range_scan_async( self, from_commit_zip_file: InMemoryZip,