diff --git a/README.md b/README.md index 7966361b..82575d6f 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ This guide walks you through both installation and usage. 1. [Options](#options) 1. [Severity Threshold](#severity-option) 2. [Monitor](#monitor-option) - 3. [Package Vulnerabilities](#package-vulnerabilities-option) - 4. [License Compliance](#license-compliance-option) - 5. [Lock Restore](#lock-restore-option) + 3. [Cycode Report](#cycode-report-option) + 4. [Package Vulnerabilities](#package-vulnerabilities-option) + 5. [License Compliance](#license-compliance-option) + 6. [Lock Restore](#lock-restore-option) 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) 3. [Path Scan](#path-scan) @@ -300,6 +301,7 @@ The Cycode CLI application offers several types of scans so that you can choose | `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | | `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | | `--monitor` | When specified, the scan results will be recorded in the knowledge graph. Please note that when working in `monitor` mode, the knowledge graph will not be updated as a result of SCM events (Push, Repo creation). (Supported for SCA scan type only). | +| `--cycode-report` | When specified, displays a link to the scan report in the Cycode platform in the console output. | | `--no-restore` | When specified, Cycode will not run restore command. Will scan direct dependencies ONLY! | | `--gradle-all-sub-projects` | When specified, Cycode will run gradle restore command for all sub projects. Should run from root project directory ONLY! | | `--help` | Show options for given command. | @@ -337,6 +339,25 @@ When using this option, the scan results from this scan will appear in the knowl > [!WARNING] > You must be an `owner` or an `admin` in Cycode to view the knowledge graph page. +#### Cycode Report Option + +For every scan performed using the Cycode CLI, a report is automatically generated and its results are sent to Cycode. These results are tied to the relevant policies (e.g., [SCA policies](https://docs.cycode.com/docs/sca-policies) for Repository scans) within the Cycode platform. + +To have the direct URL to this Cycode report printed in your CLI output after the scan completes, add the argument `--cycode-report` to your scan command. + +`cycode scan --cycode-report repository ~/home/git/codebase` + +All scan results from the CLI will appear in the CLI Logs section of Cycode. If you included the `--cycode-report` flag in your command, a direct link to the specific report will be displayed in your terminal following the scan results. + +> [!WARNING] +> You must be an `owner` or an `admin` in Cycode to view this page. + +![cli-report](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/sca_report_url.png) + +The report page will look something like below: + +![](https://raw.githubusercontent.com/cycodehq/cycode-cli/main/images/scan_details.png) + #### Package Vulnerabilities Option > [!NOTE] diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 04aae2e3..a40a066e 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -323,7 +323,7 @@ def scan_documents( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar=progress_bar ) - aggregation_report_url = _try_get_aggregation_report_url(scan_parameters, ctx.obj['client'], scan_type) + aggregation_report_url = _try_get_aggregation_report_url_if_needed(scan_parameters, ctx.obj['client'], scan_type) _set_aggregation_report_url(ctx, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) @@ -641,6 +641,7 @@ def parse_pre_receive_input() -> str: def _get_default_scan_parameters(ctx: typer.Context) -> dict: return { 'monitor': ctx.obj.get('monitor'), + 'report': ctx.obj.get('report'), 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), 'license_compliance': ctx.obj.get('license-compliance'), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility @@ -956,7 +957,7 @@ def _get_scan_result( did_detect=True, detections_per_file=_map_detections_per_file_and_commit_id(scan_type, scan_raw_detections), scan_id=scan_id, - report_url=_try_get_aggregation_report_url(scan_parameters, cycode_client, scan_type), + report_url=_try_get_aggregation_report_url_if_needed(scan_parameters, cycode_client, scan_type), ) @@ -972,9 +973,12 @@ def _set_aggregation_report_url(ctx: typer.Context, aggregation_report_url: Opti ctx.obj['aggregation_report_url'] = aggregation_report_url -def _try_get_aggregation_report_url( +def _try_get_aggregation_report_url_if_needed( scan_parameters: dict, cycode_client: 'ScanClient', scan_type: str ) -> Optional[str]: + if not scan_parameters.get('report', False): + return None + aggregation_id = scan_parameters.get('aggregation_id') if aggregation_id is None: return None diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 2d323706..a2ffb550 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -45,6 +45,13 @@ def scan_command( '--sync', help='Run scan synchronously (INTERNAL FOR IDEs).', show_default='asynchronously', hidden=True ), ] = False, + report: Annotated[ + bool, + typer.Option( + '--cycode-report', + help='When specified, displays a link to the scan report in the Cycode platform in the console output.', + ), + ] = False, show_secret: Annotated[ bool, typer.Option('--show-secret', help='Show Secrets in plain text.', rich_help_panel=_SECRET_RICH_HELP_PANEL) ] = False, @@ -136,6 +143,7 @@ def scan_command( ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold ctx.obj['monitor'] = monitor + ctx.obj['report'] = report if export_type and export_file: console_printer = ctx.obj['console_printer'] diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index bdbce37f..e0bf8131 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,4 +1,5 @@ import json +from copy import deepcopy from typing import TYPE_CHECKING, Union from uuid import UUID @@ -73,6 +74,11 @@ def zipped_file_scan_sync( is_git_diff: bool = False, ) -> models.ScanResultsSyncFlow: files = {'file': ('multiple_files_scan.zip', zip_file.read())} + + scan_parameters = deepcopy(scan_parameters) # avoid mutating the original dict + if 'report' in scan_parameters: + del scan_parameters['report'] # BE raises validation error instead of ignoring it + response = self.scan_cycode_client.post( url_path=self.get_zipped_file_scan_sync_url_path(scan_type), data={ diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 9372ede0..9ef09123 100644 --- a/tests/test_code_scanner.py +++ b/tests/test_code_scanner.py @@ -6,7 +6,7 @@ from cycode.cli import consts from cycode.cli.apps.scan.code_scanner import ( - _try_get_aggregation_report_url, + _try_get_aggregation_report_url_if_needed, ) from cycode.cli.cli_types import ScanTypeOption from cycode.cli.files_collector.excluder import _is_relevant_file_to_scan @@ -29,7 +29,7 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( ) -> None: aggregation_id = uuid4().hex scan_parameter = {'aggregation_id': aggregation_id} - result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result is None @@ -37,7 +37,8 @@ def test_try_get_aggregation_report_url_if_no_report_command_needed_return_none( def test_try_get_aggregation_report_url_if_no_aggregation_id_needed_return_none( scan_type: ScanTypeOption, scan_client: ScanClient ) -> None: - result = _try_get_aggregation_report_url({}, scan_client, scan_type) + scan_parameter = {'report': True} + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result is None @@ -47,12 +48,12 @@ def test_try_get_aggregation_report_url_if_needed_return_result( scan_type: ScanTypeOption, scan_client: ScanClient, api_token_response: responses.Response ) -> None: aggregation_id = uuid4() - scan_parameter = {'aggregation_id': aggregation_id} + scan_parameter = {'report': True, 'aggregation_id': aggregation_id} url = get_scan_aggregation_report_url(aggregation_id, scan_client, scan_type) responses.add(api_token_response) # mock token based client responses.add(get_scan_aggregation_report_url_response(url, aggregation_id)) scan_aggregation_report_url_response = scan_client.get_scan_aggregation_report_url(str(aggregation_id), scan_type) - result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) assert result == scan_aggregation_report_url_response.report_url