diff --git a/README.md b/README.md index fbe5c6a6..7966361b 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,9 @@ This guide walks you through both installation and usage. 1. [Options](#options) 1. [Severity Threshold](#severity-option) 2. [Monitor](#monitor-option) - 3. [Report](#report-option) - 4. [Package Vulnerabilities](#package-vulnerabilities-option) - 5. [License Compliance](#license-compliance-option) - 6. [Lock Restore](#lock-restore-option) + 3. [Package Vulnerabilities](#package-vulnerabilities-option) + 4. [License Compliance](#license-compliance-option) + 5. [Lock Restore](#lock-restore-option) 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) 3. [Path Scan](#path-scan) @@ -282,7 +281,7 @@ The following are the options and commands available with the Cycode CLI applica | [configure](#using-the-configure-command) | Initial command to configure your CLI client authentication. | | [ignore](#ignoring-scan-results) | Ignores a specific value, path or rule ID. | | [scan](#running-a-scan) | Scan the content for Secrets/IaC/SCA/SAST violations. You`ll need to specify which scan type to perform: commit-history/path/repository/etc. | -| [report](#report-command) | Generate report. You`ll need to specify which report type to perform. | +| [report](#report-command) | Generate report. You`ll need to specify which report type to perform as SBOM. | | status | Show the CLI status and exit. | # Scan Command @@ -301,7 +300,6 @@ 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). | -| `--report` | When specified, a violations report will be generated. A URL link to the report will be printed as an output to the command execution. | | `--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. | @@ -339,28 +337,6 @@ 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. -#### Report Option - -> [!NOTE] -> This option is not available to IaC scans. - -To push scan results tied to the [SCA policies](https://docs.cycode.com/docs/sca-policies) found in the Repository scan to Cycode, add the argument `--report` to the scan command. - -`cycode scan -t sca --report repository ~/home/git/codebase` - -In the same way, you can push scan results of Secrets and SAST scans to Cycode by adding the `--report` option to the scan command. - -When using this option, the scan results from this scan will appear in the On-Demand Scans section of Cycode. To get to this page, click the link that appears after the printed 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/__main__.py b/cycode/__main__.py new file mode 100644 index 00000000..7ad8ef7e --- /dev/null +++ b/cycode/__main__.py @@ -0,0 +1,4 @@ +from cycode.cli.consts import PROGRAM_NAME +from cycode.cli.main import app + +app(prog_name=PROGRAM_NAME) diff --git a/cycode/cli/__main__.py b/cycode/cli/__main__.py deleted file mode 100644 index dad7ac12..00000000 --- a/cycode/cli/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from cycode.cli.main import app - -app() diff --git a/cycode/cli/app.py b/cycode/cli/app.py index b07b3221..6fd4f70d 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -1,14 +1,14 @@ import logging -from pathlib import Path from typing import Annotated, Optional import typer from typer import rich_utils +from typer._completion_classes import completion_init from typer.completion import install_callback, show_callback from cycode import __version__ from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status -from cycode.cli.cli_types import ExportTypeOption, OutputTypeOption +from cycode.cli.cli_types import OutputTypeOption from cycode.cli.consts import CLI_CONTEXT_SETTINGS from cycode.cli.printers import ConsolePrinter from cycode.cli.user_settings.configuration_manager import ConfigurationManager @@ -24,14 +24,10 @@ # By default, it uses blue color which is too dark for some terminals rich_utils.RICH_HELP = "Try [cyan]'{command_path} {help_option}'[/] for help." +completion_init() # DO NOT TOUCH; this is required for the completion to work properly _cycode_cli_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md' -_cycode_cli_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_cycode_cli_docs}]{_cycode_cli_docs}[/link] -""" +_cycode_cli_epilog = f'[bold]Documentation:[/] [link={_cycode_cli_docs}]{_cycode_cli_docs}[/link]' app = typer.Typer( pretty_exceptions_show_locals=False, @@ -64,13 +60,14 @@ def check_latest_version_on_close(ctx: typer.Context) -> None: def export_if_needed_on_close(ctx: typer.Context) -> None: + scan_finalized = ctx.obj.get('scan_finalized') printer = ctx.obj.get('console_printer') - if printer.is_recording: + if scan_finalized and printer.is_recording: printer.export() +_AUTH_RICH_HELP_PANEL = 'Authentication options' _COMPLETION_RICH_HELP_PANEL = 'Completion options' -_EXPORT_RICH_HELP_PANEL = 'Export options' @app.callback() @@ -90,25 +87,18 @@ def app_callback( Optional[str], typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'), ] = None, - export_type: Annotated[ - ExportTypeOption, + client_secret: Annotated[ + Optional[str], typer.Option( - '--export-type', - case_sensitive=False, - help='Specify the export type. ' - 'HTML and SVG will export terminal output and rely on --output option. ' - 'JSON always exports JSON.', - rich_help_panel=_EXPORT_RICH_HELP_PANEL, + help='Specify a Cycode client secret for this specific scan execution.', + rich_help_panel=_AUTH_RICH_HELP_PANEL, ), - ] = ExportTypeOption.JSON, - export_file: Annotated[ - Optional[Path], + ] = None, + client_id: Annotated[ + Optional[str], typer.Option( - '--export-file', - help='Export file. Path to the file where the export will be saved. ', - dir_okay=False, - writable=True, - rich_help_panel=_EXPORT_RICH_HELP_PANEL, + help='Specify a Cycode client ID for this specific scan execution.', + rich_help_panel=_AUTH_RICH_HELP_PANEL, ), ] = None, _: Annotated[ @@ -150,10 +140,11 @@ def app_callback( if output == OutputTypeOption.JSON: no_progress_meter = True + ctx.obj['client_id'] = client_id + ctx.obj['client_secret'] = client_secret + ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS) - ctx.obj['export_type'] = export_type - ctx.obj['export_file'] = export_file ctx.obj['console_printer'] = ConsolePrinter(ctx) ctx.call_on_close(lambda: export_if_needed_on_close(ctx)) diff --git a/cycode/cli/apps/ai_remediation/__init__.py b/cycode/cli/apps/ai_remediation/__init__.py index cd471a08..00d0c7c5 100644 --- a/cycode/cli/apps/ai_remediation/__init__.py +++ b/cycode/cli/apps/ai_remediation/__init__.py @@ -4,9 +4,9 @@ app = typer.Typer() -_ai_remediation_epilog = """ -Note: AI remediation suggestions are generated automatically and should be reviewed before applying. -""" +_ai_remediation_epilog = ( + 'Note: AI remediation suggestions are generated automatically and should be reviewed before applying.' +) app.command( name='ai-remediation', diff --git a/cycode/cli/apps/ai_remediation/ai_remediation_command.py b/cycode/cli/apps/ai_remediation/ai_remediation_command.py index ea5ef826..ab2eca5e 100644 --- a/cycode/cli/apps/ai_remediation/ai_remediation_command.py +++ b/cycode/cli/apps/ai_remediation/ai_remediation_command.py @@ -24,7 +24,7 @@ def ai_remediation_command( * `cycode ai-remediation `: View remediation guidance * `cycode ai-remediation --fix`: Apply suggested fixes """ - client = get_scan_cycode_client() + client = get_scan_cycode_client(ctx) try: remediation_markdown = client.get_ai_remediation(detection_id) diff --git a/cycode/cli/apps/auth/__init__.py b/cycode/cli/apps/auth/__init__.py index beecae38..f487e1bf 100644 --- a/cycode/cli/apps/auth/__init__.py +++ b/cycode/cli/apps/auth/__init__.py @@ -3,12 +3,7 @@ from cycode.cli.apps.auth.auth_command import auth_command _auth_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-auth-command' -_auth_command_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_auth_command_docs}]{_auth_command_docs}[/link] -""" +_auth_command_epilog = f'[bold]Documentation:[/] [link={_auth_command_docs}]{_auth_command_docs}[/link]' app = typer.Typer(no_args_is_help=False) app.command(name='auth', epilog=_auth_command_epilog, short_help='Authenticate your machine with Cycode.')(auth_command) diff --git a/cycode/cli/apps/auth/auth_common.py b/cycode/cli/apps/auth/auth_common.py index 52b7b6fa..96fec4cf 100644 --- a/cycode/cli/apps/auth/auth_common.py +++ b/cycode/cli/apps/auth/auth_common.py @@ -13,7 +13,10 @@ def get_authorization_info(ctx: 'Context') -> Optional[AuthInfo]: printer = ctx.obj.get('console_printer') - client_id, client_secret = CredentialsManager().get_credentials() + client_id, client_secret = ctx.obj.get('client_id'), ctx.obj.get('client_secret') + if not client_id or not client_secret: + client_id, client_secret = CredentialsManager().get_credentials() + if not client_id or not client_secret: return None diff --git a/cycode/cli/apps/configure/__init__.py b/cycode/cli/apps/configure/__init__.py index ce73c450..4944a3e3 100644 --- a/cycode/cli/apps/configure/__init__.py +++ b/cycode/cli/apps/configure/__init__.py @@ -3,12 +3,7 @@ from cycode.cli.apps.configure.configure_command import configure_command _configure_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#using-the-configure-command' -_configure_command_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_configure_command_docs}]{_configure_command_docs}[/link] -""" +_configure_command_epilog = f'[bold]Documentation:[/] [link={_configure_command_docs}]{_configure_command_docs}[/link]' app = typer.Typer(no_args_is_help=True) diff --git a/cycode/cli/apps/report/__init__.py b/cycode/cli/apps/report/__init__.py index 40cc696a..751157a4 100644 --- a/cycode/cli/apps/report/__init__.py +++ b/cycode/cli/apps/report/__init__.py @@ -4,5 +4,5 @@ from cycode.cli.apps.report.report_command import report_command app = typer.Typer(name='report', no_args_is_help=True) -app.callback(short_help='Generate report. You`ll need to specify which report type to perform.')(report_command) +app.callback(short_help='Generate report. You`ll need to specify which report type to perform as SBOM.')(report_command) app.add_typer(sbom.app) diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index 20e82848..9741aa73 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -24,7 +24,7 @@ def path_command( ) -> None: add_breadcrumb('path') - client = get_report_cycode_client() + client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_format = report_parameters.output_format output_file = ctx.obj['output_file'] diff --git a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py index 28be0114..9e2f4885 100644 --- a/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py +++ b/cycode/cli/apps/report/sbom/repository_url/repository_url_command.py @@ -20,7 +20,7 @@ def repository_url_command( progress_bar.start() progress_bar.set_section_length(SbomReportProgressBarSection.PREPARE_LOCAL_FILES) - client = get_report_cycode_client() + client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] output_file = ctx.obj['output_file'] output_format = report_parameters.output_format diff --git a/cycode/cli/apps/scan/__init__.py b/cycode/cli/apps/scan/__init__.py index ada2d105..b4d8ab79 100644 --- a/cycode/cli/apps/scan/__init__.py +++ b/cycode/cli/apps/scan/__init__.py @@ -10,12 +10,7 @@ app = typer.Typer(name='scan', no_args_is_help=True) _scan_command_docs = 'https://github.com/cycodehq/cycode-cli/blob/main/README.md#scan-command' -_scan_command_epilog = f"""[bold]Documentation[/] - - - -For more details and advanced usage, visit: [link={_scan_command_docs}]{_scan_command_docs}[/link] -""" +_scan_command_epilog = f'[bold]Documentation:[/] [link={_scan_command_docs}]{_scan_command_docs}[/link]' app.callback( short_help='Scan the content for Secrets, IaC, SCA, and SAST violations.', diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 0209d9da..04aae2e3 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_if_needed(scan_parameters, ctx.obj['client'], scan_type) + aggregation_report_url = _try_get_aggregation_report_url(scan_parameters, ctx.obj['client'], scan_type) _set_aggregation_report_url(ctx, aggregation_report_url) progress_bar.set_section_length(ScanProgressBarSection.GENERATE_REPORT, 1) @@ -571,6 +571,7 @@ def print_results( ctx: typer.Context, local_scan_results: list[LocalScanResult], errors: Optional[dict[str, 'CliError']] = None ) -> None: printer = ctx.obj.get('console_printer') + printer.update_ctx(ctx) printer.print_scan_results(local_scan_results, errors) @@ -640,7 +641,6 @@ 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 +956,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_if_needed(scan_parameters, cycode_client, scan_type), + report_url=_try_get_aggregation_report_url(scan_parameters, cycode_client, scan_type), ) @@ -972,12 +972,9 @@ 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_if_needed( +def _try_get_aggregation_report_url( 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 38e4a610..2d323706 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -1,9 +1,10 @@ +from pathlib import Path from typing import Annotated, Optional import click import typer -from cycode.cli.cli_types import ScanTypeOption, ScaScanTypeOption, SeverityOption +from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, @@ -12,8 +13,9 @@ from cycode.cli.utils.get_api_client import get_scan_cycode_client from cycode.cli.utils.sentry import add_breadcrumb -_AUTH_RICH_HELP_PANEL = 'Authentication options' +_EXPORT_RICH_HELP_PANEL = 'Export options' _SCA_RICH_HELP_PANEL = 'SCA options' +_SECRET_RICH_HELP_PANEL = 'Secret options' def scan_command( @@ -27,21 +29,6 @@ def scan_command( case_sensitive=False, ), ] = ScanTypeOption.SECRET, - client_secret: Annotated[ - Optional[str], - typer.Option( - help='Specify a Cycode client secret for this specific scan execution.', - rich_help_panel=_AUTH_RICH_HELP_PANEL, - ), - ] = None, - client_id: Annotated[ - Optional[str], - typer.Option( - help='Specify a Cycode client ID for this specific scan execution.', - rich_help_panel=_AUTH_RICH_HELP_PANEL, - ), - ] = None, - show_secret: Annotated[bool, typer.Option('--show-secret', help='Show Secrets in plain text.')] = False, soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, @@ -58,13 +45,8 @@ def scan_command( '--sync', help='Run scan synchronously (INTERNAL FOR IDEs).', show_default='asynchronously', hidden=True ), ] = False, - report: Annotated[ - bool, - typer.Option( - '--report', - help='When specified, generates a violations report. ' - 'A link to the report will be displayed in the console output.', - ), + show_secret: Annotated[ + bool, typer.Option('--show-secret', help='Show Secrets in plain text.', rich_help_panel=_SECRET_RICH_HELP_PANEL) ] = False, sca_scan: Annotated[ list[ScaScanTypeOption], @@ -98,6 +80,27 @@ def scan_command( rich_help_panel=_SCA_RICH_HELP_PANEL, ), ] = False, + export_type: Annotated[ + ExportTypeOption, + typer.Option( + '--export-type', + case_sensitive=False, + help='Specify the export type. ' + 'HTML and SVG will export terminal output and rely on --output option. ' + 'JSON always exports JSON.', + rich_help_panel=_EXPORT_RICH_HELP_PANEL, + ), + ] = None, + export_file: Annotated[ + Optional[Path], + typer.Option( + '--export-file', + help='Export file. Path to the file where the export will be saved.', + dir_okay=False, + writable=True, + rich_help_panel=_EXPORT_RICH_HELP_PANEL, + ), + ] = None, ) -> None: """:mag: [bold cyan]Scan code for vulnerabilities (Secrets, IaC, SCA, SAST).[/] @@ -115,14 +118,28 @@ def scan_command( """ add_breadcrumb('scan') + if export_file and export_type is None: + raise typer.BadParameter( + 'Export type must be specified when --export-file is provided.', + param_hint='--export-type', + ) + if export_type and export_file is None: + raise typer.BadParameter( + 'Export file must be specified when --export-type is provided.', + param_hint='--export-file', + ) + ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail - ctx.obj['client'] = get_scan_cycode_client(client_id, client_secret, not ctx.obj['show_secret']) + ctx.obj['client'] = get_scan_cycode_client(ctx) ctx.obj['scan_type'] = scan_type 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'] + console_printer.enable_recording(export_type, export_file) _ = no_restore, gradle_all_sub_projects # they are actually used; via ctx.params @@ -136,7 +153,8 @@ def _sca_scan_to_context(ctx: typer.Context, sca_scan_user_selected: list[str]) @click.pass_context def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: - add_breadcrumb('scan_finalize') + add_breadcrumb('scan_finalized') + ctx.obj['scan_finalized'] = True progress_bar = ctx.obj.get('progress_bar') if progress_bar: diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 0a272c57..0cf6e8fd 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -22,7 +22,7 @@ def get_cli_status(ctx: 'Context') -> CliStatus: supported_modules_status = CliSupportedModulesStatus() if is_authenticated: try: - client = get_scan_cycode_client() + client = get_scan_cycode_client(ctx) supported_modules_preferences = client.get_supported_modules_preferences() supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning diff --git a/cycode/cli/printers/console_printer.py b/cycode/cli/printers/console_printer.py index f581c894..17c402ff 100644 --- a/cycode/cli/printers/console_printer.py +++ b/cycode/cli/printers/console_printer.py @@ -16,6 +16,8 @@ from cycode.cli.printers.text_printer import TextPrinter if TYPE_CHECKING: + from pathlib import Path + from cycode.cli.models import LocalScanResult from cycode.cli.printers.tables.table_printer_base import PrinterBase @@ -43,17 +45,9 @@ def __init__( self.console_err = console_err_override or console_err self.output_type = output_type_override or self.ctx.obj.get('output') - self.console_record = None - - self.export_type = self.ctx.obj.get('export_type') - self.export_file = self.ctx.obj.get('export_file') - if console_override is None and self.export_type and self.export_file: - self.console_record = ConsolePrinter( - ctx, - console_override=Console(record=True, file=io.StringIO()), - console_err_override=Console(stderr=True, record=True, file=io.StringIO()), - output_type_override='json' if self.export_type == 'json' else self.output_type, - ) + self.export_type: Optional[str] = None + self.export_file: Optional[Path] = None + self.console_record: Optional[ConsolePrinter] = None @property def scan_type(self) -> str: @@ -76,6 +70,21 @@ def printer(self) -> 'PrinterBase': return printer_class(self.ctx, self.console, self.console_err) + def update_ctx(self, ctx: 'typer.Context') -> None: + self.ctx = ctx + + def enable_recording(self, export_type: str, export_file: 'Path') -> None: + if self.console_record is None: + self.export_file = export_file + self.export_type = export_type + + self.console_record = ConsolePrinter( + self.ctx, + console_override=Console(record=True, file=io.StringIO()), + console_err_override=Console(stderr=True, record=True, file=io.StringIO()), + output_type_override='json' if self.export_type == 'json' else self.output_type, + ) + def print_scan_results( self, local_scan_results: list['LocalScanResult'], @@ -106,16 +115,18 @@ def export(self) -> None: if self.console_record is None: raise CycodeError('Console recording was not enabled. Cannot export.') - if not self.export_file.suffix: + export_file = self.export_file + if not export_file.suffix: # resolve file extension based on the export type if not provided in the file name - self.export_file = self.export_file.with_suffix(f'.{self.export_type.lower()}') + export_file = export_file.with_suffix(f'.{self.export_type.lower()}') + export_file = str(export_file) if self.export_type is ExportTypeOption.HTML: - self.console_record.console.save_html(self.export_file) + self.console_record.console.save_html(export_file) elif self.export_type is ExportTypeOption.SVG: - self.console_record.console.save_svg(self.export_file, title=consts.APP_NAME) + self.console_record.console.save_svg(export_file, title=consts.APP_NAME) elif self.export_type is ExportTypeOption.JSON: - with open(self.export_file, 'w', encoding='UTF-8') as f: + with open(export_file, 'w', encoding='UTF-8') as f: self.console_record.console.file.seek(0) f.write(self.console_record.console.file.read()) else: diff --git a/cycode/cli/printers/printer_base.py b/cycode/cli/printers/printer_base.py index 527cc31b..69596e2a 100644 --- a/cycode/cli/printers/printer_base.py +++ b/cycode/cli/printers/printer_base.py @@ -20,10 +20,10 @@ class PrinterBase(ABC): NO_DETECTIONS_MESSAGE = ( - '[green]Good job! No issues were found!!! :clapping_hands::clapping_hands::clapping_hands:[/]' + '[b green]Good job! No issues were found!!! :clapping_hands::clapping_hands::clapping_hands:[/]' ) FAILED_SCAN_MESSAGE = ( - '[red]Unfortunately, Cycode was unable to complete the full scan. ' + '[b red]Unfortunately, Cycode was unable to complete the full scan. ' 'Please note that not all results may be available:[/]' ) @@ -99,6 +99,7 @@ def print_scan_results_summary(self, local_scan_results: list['LocalScanResult'] detections_count += 1 severity_counts[SeverityOption(detection.severity)] += 1 + self.console.line() self.console.print(f'[bold]Cycode found {detections_count} violations[/]', end=': ') # Example of string: CRITICAL - 6 | HIGH - 0 | MEDIUM - 14 | LOW - 0 | INFO - 0 @@ -110,3 +111,5 @@ def print_scan_results_summary(self, local_scan_results: list['LocalScanResult'] self.console.print( SeverityOption.get_member_emoji(severity), severity, '-', severity_counts[severity], end=end ) + + self.console.line() diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index b2ed1a2e..755278d6 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -111,6 +111,8 @@ def _print_violation_card( detection, document, obfuscate=not self.show_secret, + lines_to_display_before=3, + lines_to_display_after=3, ), title=':computer: Code Snippet', ) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 0bf59a20..1bf358c8 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -7,6 +7,7 @@ from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase +from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.sca_ordering import sort_and_group_detections from cycode.cli.utils.string_utils import shortcut_dependency_paths @@ -31,23 +32,19 @@ class ScaTablePrinter(TablePrinterBase): def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: - aggregation_report_url = self.ctx.obj.get('aggregation_report_url') detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results) for policy_id, detections in detections_per_policy_id.items(): table = self._get_table(policy_id) resulting_detections, group_separator_indexes = sort_and_group_detections(detections) for detection in resulting_detections: - self._enrich_table_with_values(policy_id, table, detection) + self._enrich_table_with_values(table, detection) table.set_group_separator_indexes(group_separator_indexes) self._print_summary_issues(len(detections), self._get_title(policy_id)) self._print_table(table) - self.print_scan_results_summary(local_scan_results) - self._print_report_urls(local_scan_results, aggregation_report_url) - @staticmethod def _get_title(policy_id: str) -> str: if policy_id == PACKAGE_VULNERABILITY_POLICY_ID: @@ -66,7 +63,7 @@ def _get_table(self, policy_id: str) -> Table: elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: table.add_column(LICENSE_COLUMN) - if self._is_git_repository(): + if is_git_diff_based_scan(self.scan_type, self.command_scan_type): table.add_column(REPOSITORY_COLUMN) table.add_column(SEVERITY_COLUMN) @@ -80,7 +77,7 @@ def _get_table(self, policy_id: str) -> Table: return table @staticmethod - def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection) -> None: + def _enrich_table_with_values(table: Table, detection: Detection) -> None: detection_details = detection.detection_details if detection.severity: diff --git a/cycode/cli/printers/tables/table_printer.py b/cycode/cli/printers/tables/table_printer.py index fe9f8dd5..6fc85a1b 100644 --- a/cycode/cli/printers/tables/table_printer.py +++ b/cycode/cli/printers/tables/table_printer.py @@ -6,6 +6,7 @@ from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase +from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text @@ -37,8 +38,6 @@ def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: table.set_group_separator_indexes(group_separator_indexes) self._print_table(table) - self.print_scan_results_summary(local_scan_results) - self._print_report_urls(local_scan_results, self.ctx.obj.get('aggregation_report_url')) def _get_table(self) -> Table: table = Table() @@ -49,7 +48,7 @@ def _get_table(self) -> Table: table.add_column(LINE_NUMBER_COLUMN) table.add_column(COLUMN_NUMBER_COLUMN) - if self._is_git_repository(): + if is_git_diff_based_scan(self.scan_type, self.command_scan_type): table.add_column(COMMIT_SHA_COLUMN) if self.scan_type == SECRET_SCAN_TYPE: diff --git a/cycode/cli/printers/tables/table_printer_base.py b/cycode/cli/printers/tables/table_printer_base.py index d7a2b502..8cb4cbda 100644 --- a/cycode/cli/printers/tables/table_printer_base.py +++ b/cycode/cli/printers/tables/table_printer_base.py @@ -11,11 +11,15 @@ class TablePrinterBase(PrinterBase, abc.ABC): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.text_printer = TextPrinter(self.ctx, self.console, self.console_err) + def print_result(self, result: CliResult) -> None: - TextPrinter(self.ctx, self.console, self.console_err).print_result(result) + self.text_printer.print_result(result) def print_error(self, error: CliError) -> None: - TextPrinter(self.ctx, self.console, self.console_err).print_error(error) + self.text_printer.print_error(error) def print_scan_results( self, local_scan_results: list['LocalScanResult'], errors: Optional[dict[str, 'CliError']] = None @@ -26,16 +30,8 @@ def print_scan_results( self._print_results(local_scan_results) - if not errors: - return - - self.console.print(self.FAILED_SCAN_MESSAGE) - for scan_id, error in errors.items(): - self.console.print(f'- {scan_id}: ', end='') - self.print_error(error) - - def _is_git_repository(self) -> bool: - return self.ctx.info_name in {'commit_history', 'pre_commit', 'pre_receive'} and 'remote_url' in self.ctx.obj + self.print_scan_results_summary(local_scan_results) + self.text_printer.print_report_urls_and_errors(local_scan_results, errors) @abc.abstractmethod def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: @@ -44,19 +40,3 @@ def _print_results(self, local_scan_results: list['LocalScanResult']) -> None: def _print_table(self, table: 'Table') -> None: if table.get_rows(): self.console.print(table.get_table()) - - def _print_report_urls( - self, - local_scan_results: list['LocalScanResult'], - aggregation_report_url: Optional[str] = None, - ) -> None: - report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url] - if not report_urls and not aggregation_report_url: - return - if aggregation_report_url: - self.console.print(f'Report URL: {aggregation_report_url}') - return - - self.console.print('Report URLs:') - for report_url in report_urls: - self.console.print(f'- {report_url}') diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 564456ae..05a360fd 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -98,10 +98,15 @@ def print_report_urls_and_errors( def print_report_urls(self, report_urls: list[str], aggregation_report_url: Optional[str] = None) -> None: if not report_urls and not aggregation_report_url: return - if aggregation_report_url: - self.console.print(f'Report URL: {aggregation_report_url}') + + # Prioritize aggregation report URL; if report urls is only one, use it instead + single_url = report_urls[0] if len(report_urls) == 1 else None + single_url = aggregation_report_url or single_url + if single_url: + self.console.print(f'[b]Report URL:[/] {single_url}') return - self.console.print('Report URLs:') + # If there are multiple report URLs, print them all + self.console.print('[b]Report URLs:[/]') for report_url in report_urls: self.console.print(f'- {report_url}') diff --git a/cycode/cli/printers/utils/__init__.py b/cycode/cli/printers/utils/__init__.py index e69de29b..e1676c35 100644 --- a/cycode/cli/printers/utils/__init__.py +++ b/cycode/cli/printers/utils/__init__.py @@ -0,0 +1,8 @@ +from cycode.cli import consts + + +def is_git_diff_based_scan(scan_type: str, command_scan_type: str) -> bool: + return ( + command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES + and scan_type in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES + ) diff --git a/cycode/cli/printers/utils/code_snippet_syntax.py b/cycode/cli/printers/utils/code_snippet_syntax.py index aae33872..12501544 100644 --- a/cycode/cli/printers/utils/code_snippet_syntax.py +++ b/cycode/cli/printers/utils/code_snippet_syntax.py @@ -1,10 +1,10 @@ -import math from typing import TYPE_CHECKING from rich.syntax import Syntax from cycode.cli import consts from cycode.cli.console import _SYNTAX_HIGHLIGHT_THEME +from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.utils.string_utils import get_position_in_line, obfuscate_text if TYPE_CHECKING: @@ -12,8 +12,8 @@ from cycode.cyclient.models import Detection -def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int: - start_line = detection_line - math.ceil(lines_to_display / 2) +def _get_code_segment_start_line(detection_line: int, lines_to_display_before: int) -> int: + start_line = detection_line - lines_to_display_before return 0 if start_line < 0 else start_line @@ -26,17 +26,24 @@ def get_detection_line(scan_type: str, detection: 'Detection') -> int: def _get_code_snippet_syntax_from_file( - scan_type: str, detection: 'Detection', document: 'Document', lines_to_display: int, obfuscate: bool + scan_type: str, + detection: 'Detection', + document: 'Document', + lines_to_display_before: int, + lines_to_display_after: int, + obfuscate: bool, ) -> Syntax: detection_details = detection.detection_details detection_line = get_detection_line(scan_type, detection) - start_line_index = _get_code_segment_start_line(detection_line, lines_to_display) + start_line_index = _get_code_segment_start_line(detection_line, lines_to_display_before) detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1)) violation_length = detection_details.get('length', -1) code_lines_to_render = [] document_content_lines = document.content.splitlines() - for line_index in range(lines_to_display): + total_lines_to_display = lines_to_display_before + 1 + lines_to_display_after + + for line_index in range(total_lines_to_display): current_line_index = start_line_index + line_index if current_line_index >= len(document_content_lines): break @@ -56,6 +63,7 @@ def _get_code_snippet_syntax_from_file( code=code_to_render, lexer=Syntax.guess_lexer(document.path, code=code_to_render), line_numbers=True, + word_wrap=True, dedent=True, tab_size=2, start_line=start_line_index + 1, @@ -91,23 +99,19 @@ def _get_code_snippet_syntax_from_git_diff( ) -def _is_git_diff_based_scan(scan_type: str, command_scan_type: str) -> bool: - return ( - command_scan_type in consts.COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES - and scan_type in consts.COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES - ) - - def get_code_snippet_syntax( scan_type: str, command_scan_type: str, detection: 'Detection', document: 'Document', - lines_to_display: int = 3, + lines_to_display_before: int = 1, + lines_to_display_after: int = 1, obfuscate: bool = True, ) -> Syntax: - if _is_git_diff_based_scan(scan_type, command_scan_type): + if is_git_diff_based_scan(scan_type, command_scan_type): # it will return syntax with just one line return _get_code_snippet_syntax_from_git_diff(scan_type, detection, document, obfuscate) - return _get_code_snippet_syntax_from_file(scan_type, detection, document, lines_to_display, obfuscate) + return _get_code_snippet_syntax_from_file( + scan_type, detection, document, lines_to_display_before, lines_to_display_after, obfuscate + ) diff --git a/cycode/cli/printers/utils/detection_data.py b/cycode/cli/printers/utils/detection_data.py index 358b4c63..989a6600 100644 --- a/cycode/cli/printers/utils/detection_data.py +++ b/cycode/cli/printers/utils/detection_data.py @@ -35,9 +35,21 @@ def get_cwe_cve_link(cwe_cve: Optional[str]) -> Optional[str]: return None +def clear_cwe_name(cwe: str) -> str: + """Clear CWE. + + Intput: CWE-532: Insertion of Sensitive Information into Log File + Output: CWE-532 + """ + if cwe.startswith('CWE'): + return cwe.split(':')[0] + + return cwe + + def get_detection_clickable_cwe_cve(scan_type: str, detection: 'Detection') -> str: def link(url: str, name: str) -> str: - return f'[link={url}]{name}[/]' + return f'[link={url}]{clear_cwe_name(name)}[/]' if scan_type == consts.SCA_SCAN_TYPE: cve = detection.detection_details.get('vulnerability_id') @@ -84,5 +96,13 @@ def get_detection_file_path(scan_type: str, detection: 'Detection') -> Path: folder_path = detection.detection_details.get('file_path', '') file_name = detection.detection_details.get('file_name', '') return Path.joinpath(Path(folder_path), Path(file_name)) + if scan_type == consts.SAST_SCAN_TYPE: + file_path = detection.detection_details.get('file_path', '') + + # fix the absolute path...BE returns string which does not start with / + if not file_path.startswith('/'): + file_path = f'/{file_path}' + + return Path(file_path) return Path(detection.detection_details.get('file_name', '')) diff --git a/cycode/cli/utils/get_api_client.py b/cycode/cli/utils/get_api_client.py index 91e8f0f7..110d528b 100644 --- a/cycode/cli/utils/get_api_client.py +++ b/cycode/cli/utils/get_api_client.py @@ -6,6 +6,8 @@ from cycode.cyclient.client_creator import create_report_client, create_scan_client if TYPE_CHECKING: + import typer + from cycode.cyclient.report_client import ReportClient from cycode.cyclient.scan_client import ScanClient @@ -23,15 +25,16 @@ def _get_cycode_client( return create_client_func(client_id, client_secret, hide_response_log) -def get_scan_cycode_client( - client_id: Optional[str] = None, client_secret: Optional[str] = None, hide_response_log: bool = True -) -> 'ScanClient': +def get_scan_cycode_client(ctx: 'typer.Context') -> 'ScanClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') + hide_response_log = not ctx.obj.get('show_secret', False) return _get_cycode_client(create_scan_client, client_id, client_secret, hide_response_log) -def get_report_cycode_client( - client_id: Optional[str] = None, client_secret: Optional[str] = None, hide_response_log: bool = True -) -> 'ReportClient': +def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ReportClient': + client_id = ctx.obj.get('client_id') + client_secret = ctx.obj.get('client_secret') return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log) diff --git a/cycode/cli/utils/version_checker.py b/cycode/cli/utils/version_checker.py index 47da17c4..8fd1d005 100644 --- a/cycode/cli/utils/version_checker.py +++ b/cycode/cli/utils/version_checker.py @@ -86,7 +86,9 @@ def get_latest_version(self) -> Optional[str]: """ try: - response = self.get(f'{self.PYPI_PACKAGE_NAME}/json', timeout=self.PYPI_REQUEST_TIMEOUT) + response = self.get( + f'{self.PYPI_PACKAGE_NAME}/json', timeout=self.PYPI_REQUEST_TIMEOUT, hide_response_content_log=True + ) data = response.json() return data.get('info', {}).get('version') except Exception: @@ -203,10 +205,10 @@ def check_and_notify_update(self, current_version: str, use_cache: bool = True) should_update = bool(latest_version) if should_update: update_message = ( - '\nNew version of cycode available! ' - f'[yellow]{current_version}[/] → [bright_blue]{latest_version}[/]\n' + '\nNew release of Cycode CLI is available: ' + f'[red]{current_version}[/] -> [green]{latest_version}[/]\n' f'Changelog: [bright_blue]{self.GIT_CHANGELOG_URL_PREFIX}{latest_version}[/]\n' - f'Run [green]pip install --upgrade cycode[/] to update\n' + f'To update, run: [green]pip install --upgrade cycode[/]\n' ) console.print(update_message) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index e0bf8131..bdbce37f 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -1,5 +1,4 @@ import json -from copy import deepcopy from typing import TYPE_CHECKING, Union from uuid import UUID @@ -74,11 +73,6 @@ 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/cli/commands/test_check_latest_version_on_close.py b/tests/cli/commands/test_check_latest_version_on_close.py index b1f11e24..eccadf93 100644 --- a/tests/cli/commands/test_check_latest_version_on_close.py +++ b/tests/cli/commands/test_check_latest_version_on_close.py @@ -10,7 +10,7 @@ from tests.conftest import CLI_ENV_VARS _NEW_LATEST_VERSION = '999.0.0' # Simulate a newer version available -_UPDATE_MESSAGE_PART = 'new version of cycode available' +_UPDATE_MESSAGE_PART = 'new release of cycode cli is available' @patch.object(VersionChecker, 'check_for_update') diff --git a/tests/test_code_scanner.py b/tests/test_code_scanner.py index 9ef09123..9372ede0 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_if_needed, + _try_get_aggregation_report_url, ) 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_if_needed(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) assert result is None @@ -37,8 +37,7 @@ 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: - scan_parameter = {'report': True} - result = _try_get_aggregation_report_url_if_needed(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url({}, scan_client, scan_type) assert result is None @@ -48,12 +47,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 = {'report': True, 'aggregation_id': aggregation_id} + scan_parameter = {'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_if_needed(scan_parameter, scan_client, scan_type) + result = _try_get_aggregation_report_url(scan_parameter, scan_client, scan_type) assert result == scan_aggregation_report_url_response.report_url