Skip to content

CM-45719 - Add syntax highlight for code snippets in text output #290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion cycode/cli/printers/printer_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

class PrinterBase(ABC):
RED_COLOR_NAME = 'red'
WHITE_COLOR_NAME = 'white'
GREEN_COLOR_NAME = 'green'

def __init__(self, ctx: typer.Context) -> None:
Expand Down
222 changes: 87 additions & 135 deletions cycode/cli/printers/text_printer.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import math
import urllib.parse
from typing import TYPE_CHECKING, Dict, List, Optional

import click
import typer
from rich.console import Console
from rich.markup import escape
from rich.syntax import Syntax

from cycode.cli.cli_types import SeverityOption
from cycode.cli.consts import COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES, SECRET_SCAN_TYPE
from cycode.cli.models import CliError, CliResult, Detection, Document, DocumentDetections
from cycode.cli.printers.printer_base import PrinterBase
Expand All @@ -25,73 +29,80 @@ def print_result(self, result: CliResult) -> None:
if not result.success:
color = self.RED_COLOR_NAME

click.secho(result.message, fg=color)
typer.secho(result.message, fg=color)

if not result.data:
return

click.secho('\nAdditional data:', fg=color)
typer.secho('\nAdditional data:', fg=color)
for name, value in result.data.items():
click.secho(f'- {name}: {value}', fg=color)
typer.secho(f'- {name}: {value}', fg=color)

def print_error(self, error: CliError) -> None:
click.secho(error.message, fg=self.RED_COLOR_NAME)
typer.secho(error.message, fg=self.RED_COLOR_NAME)

def print_scan_results(
self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None
) -> None:
if not errors and all(result.issue_detected == 0 for result in local_scan_results):
click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME)
typer.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME)
return

for local_scan_result in local_scan_results:
for document_detections in local_scan_result.document_detections:
self._print_document_detections(document_detections, local_scan_result.scan_id)
self._print_document_detections(document_detections)

report_urls = [scan_result.report_url for scan_result in local_scan_results if scan_result.report_url]

self._print_report_urls(report_urls, self.ctx.obj.get('aggregation_report_url'))
if not errors:
return

click.secho(
typer.secho(
'Unfortunately, Cycode was unable to complete the full scan. '
'Please note that not all results may be available:',
fg='red',
)
for scan_id, error in errors.items():
click.echo(f'- {scan_id}: ', nl=False)
typer.echo(f'- {scan_id}: ', nl=False)
self.print_error(error)

def _print_document_detections(self, document_detections: DocumentDetections, scan_id: str) -> None:
def _print_document_detections(self, document_detections: DocumentDetections) -> None:
document = document_detections.document
for detection in document_detections.detections:
self._print_detection_summary(detection, document.path, scan_id)
self._print_detection_summary(detection, document.path)
self._print_new_line()
self._print_detection_code_segment(detection, document)
self._print_new_line()

def _print_detection_summary(self, detection: Detection, document_path: str, scan_id: str) -> None:
@staticmethod
def _print_new_line() -> None:
typer.echo()

def _print_detection_summary(self, detection: Detection, document_path: str) -> None:
detection_name = detection.type if self.scan_type == SECRET_SCAN_TYPE else detection.message
detection_name_styled = click.style(detection_name, fg='bright_red', bold=True)

detection_sha = detection.detection_details.get('sha512')
detection_sha_message = f'\nSecret SHA: {detection_sha}' if detection_sha else ''
detection_severity = detection.severity or 'N/A'
detection_severity_color = SeverityOption.get_member_color(detection_severity)
detection_severity = f'[{detection_severity_color}]{detection_severity.upper()}[/{detection_severity_color}]'

escaped_document_path = escape(urllib.parse.quote(document_path))
clickable_document_path = f'[link file://{escaped_document_path}]{document_path}'

scan_id_message = f'\nScan ID: {scan_id}'
detection_commit_id = detection.detection_details.get('commit_id')
detection_commit_id_message = f'\nCommit SHA: {detection_commit_id}' if detection_commit_id else ''

company_guidelines = detection.detection_details.get('custom_remediation_guidelines')
company_guidelines_message = f'\nCompany Guideline: {company_guidelines}' if company_guidelines else ''

click.echo(
f'⛔ '
f'Found issue of type: {detection_name_styled} '
f'(rule ID: {detection.detection_rule_id}) in file: {click.format_filename(document_path)} '
f'{detection_sha_message}'
f'{scan_id_message}'
Console().print(
f':no_entry: '
f'Found {detection_severity} issue of type: [bright_red][bold]{detection_name}[/bold][/bright_red] '
f'in file: {clickable_document_path} '
f'{detection_commit_id_message}'
f'{company_guidelines_message}'
f' ⛔'
f' :no_entry:',
highlight=True,
)

def _print_detection_code_segment(
Expand All @@ -109,145 +120,86 @@ def _print_report_urls(report_urls: List[str], aggregation_report_url: Optional[
if not report_urls and not aggregation_report_url:
return
if aggregation_report_url:
click.echo(f'Report URL: {aggregation_report_url}')
typer.echo(f'Report URL: {aggregation_report_url}')
return

click.echo('Report URLs:')
typer.echo('Report URLs:')
for report_url in report_urls:
click.echo(f'- {report_url}')
typer.echo(f'- {report_url}')

@staticmethod
def _get_code_segment_start_line(detection_line: int, lines_to_display: int) -> int:
start_line = detection_line - math.ceil(lines_to_display / 2)
return 0 if start_line < 0 else start_line

def _print_line_of_code_segment(
self,
document: Document,
line: str,
line_number: int,
detection_position_in_line: int,
violation_length: int,
is_detection_line: bool,
) -> None:
if is_detection_line:
self._print_detection_line(document, line, line_number, detection_position_in_line, violation_length)
else:
self._print_line(document, line, line_number)

def _print_detection_line(
self, document: Document, line: str, line_number: int, detection_position_in_line: int, violation_length: int
) -> None:
detection_line = self._get_detection_line_style(
line, document.is_git_diff_format, detection_position_in_line, violation_length
)

click.echo(f'{self._get_line_number_style(line_number)} {detection_line}')

def _print_line(self, document: Document, line: str, line_number: int) -> None:
line_no = self._get_line_number_style(line_number)
line = self._get_line_style(line, document.is_git_diff_format)

click.echo(f'{line_no} {line}')

def _get_detection_line_style(self, line: str, is_git_diff: bool, start_position: int, length: int) -> str:
line_color = self._get_line_color(line, is_git_diff)
if self.scan_type != SECRET_SCAN_TYPE or start_position < 0 or length < 0:
return self._get_line_style(line, is_git_diff, line_color)

violation = line[start_position : start_position + length]
if not self.show_secret:
violation = obfuscate_text(violation)

line_to_violation = line[0:start_position]
line_from_violation = line[start_position + length :]

return (
f'{self._get_line_style(line_to_violation, is_git_diff, line_color)}'
f'{self._get_line_style(violation, is_git_diff, line_color, underline=True)}'
f'{self._get_line_style(line_from_violation, is_git_diff, line_color)}'
)

def _get_line_style(
self, line: str, is_git_diff: bool, color: Optional[str] = None, underline: bool = False
) -> str:
if color is None:
color = self._get_line_color(line, is_git_diff)

return click.style(line, fg=color, bold=False, underline=underline)

def _get_line_color(self, line: str, is_git_diff: bool) -> str:
if not is_git_diff:
return self.WHITE_COLOR_NAME

if line.startswith('+'):
return self.GREEN_COLOR_NAME

if line.startswith('-'):
return self.RED_COLOR_NAME

return self.WHITE_COLOR_NAME

def _get_line_number_style(self, line_number: int) -> str:
def _get_detection_line(self, detection: Detection) -> int:
return (
f'{click.style(str(line_number), fg=self.WHITE_COLOR_NAME, bold=False)} '
f'{click.style("|", fg=self.RED_COLOR_NAME, bold=False)}'
detection.detection_details.get('line', -1)
if self.scan_type == SECRET_SCAN_TYPE
else detection.detection_details.get('line_in_file', -1) - 1
)

def _print_detection_from_file(self, detection: Detection, document: Document, lines_to_display: int) -> None:
detection_details = detection.detection_details
detection_line = (
detection_details.get('line', -1)
if self.scan_type == SECRET_SCAN_TYPE
else detection_details.get('line_in_file', -1)
)
detection_position = detection_details.get('start_position', -1)
detection_line = self._get_detection_line(detection)
start_line_index = self._get_code_segment_start_line(detection_line, lines_to_display)
detection_position = get_position_in_line(document.content, detection_details.get('start_position', -1))
violation_length = detection_details.get('length', -1)

file_content = document.content
file_lines = file_content.splitlines()
start_line = self._get_code_segment_start_line(detection_line, lines_to_display)
detection_position_in_line = get_position_in_line(file_content, detection_position)

click.echo()
code_lines_to_render = []
document_content_lines = document.content.splitlines()
for line_index in range(lines_to_display):
current_line_index = start_line + line_index
if current_line_index >= len(file_lines):
current_line_index = start_line_index + line_index
if current_line_index >= len(document_content_lines):
break

current_line = file_lines[current_line_index]
is_detection_line = current_line_index == detection_line
self._print_line_of_code_segment(
document,
current_line,
current_line_index + 1,
detection_position_in_line,
violation_length,
is_detection_line,
line_content = document_content_lines[current_line_index]

line_with_detection = current_line_index == detection_line
if self.scan_type == SECRET_SCAN_TYPE and line_with_detection and not self.show_secret:
violation = line_content[detection_position : detection_position + violation_length]
code_lines_to_render.append(line_content.replace(violation, obfuscate_text(violation)))
else:
code_lines_to_render.append(line_content)

code_to_render = '\n'.join(code_lines_to_render)
Console().print(
Syntax(
code=code_to_render,
lexer=Syntax.guess_lexer(document.path, code=code_to_render),
line_numbers=True,
dedent=True,
tab_size=2,
start_line=start_line_index + 1,
highlight_lines={
detection_line + 1,
},
)
click.echo()
)

def _print_detection_from_git_diff(self, detection: Detection, document: Document) -> None:
detection_details = detection.detection_details
detection_line_number = detection_details.get('line', -1)
detection_line_number_in_original_file = detection_details.get('line_in_file', -1)
detection_line = self._get_detection_line(detection)
detection_position = detection_details.get('start_position', -1)
violation_length = detection_details.get('length', -1)

git_diff_content = document.content
git_diff_lines = git_diff_content.splitlines()
detection_line = git_diff_lines[detection_line_number]
detection_position_in_line = get_position_in_line(git_diff_content, detection_position)

click.echo()
self._print_detection_line(
document,
detection_line,
detection_line_number_in_original_file,
detection_position_in_line,
violation_length,
line_content = document.content.splitlines()[detection_line]
detection_position_in_line = get_position_in_line(document.content, detection_position)
if self.scan_type == SECRET_SCAN_TYPE and not self.show_secret:
violation = line_content[detection_position_in_line : detection_position_in_line + violation_length]
line_content = line_content.replace(violation, obfuscate_text(violation))

Console().print(
Syntax(
line_content,
lexer='diff',
line_numbers=True,
start_line=detection_line,
dedent=True,
tab_size=2,
highlight_lines={detection_line + 1},
)
)
click.echo()

def _is_git_diff_based_scan(self) -> bool:
return self.command_scan_type in COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES and self.scan_type == SECRET_SCAN_TYPE
7 changes: 2 additions & 5 deletions cycode/cli/utils/progress_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,7 @@ def __init__(self, progress_bar_sections: ProgressBarSections) -> None:
self._current_section: ProgressBarSectionInfo = _get_initial_section(self._progress_bar_sections)
self._current_right_side_label = ''

self._progress_bar = Progress(
*_PROGRESS_BAR_COLUMNS,
transient=True,
)
self._progress_bar = Progress(*_PROGRESS_BAR_COLUMNS)
self._progress_bar_task_id = self._progress_bar.add_task(
description=self._current_section.label,
total=_PROGRESS_BAR_LENGTH,
Expand Down Expand Up @@ -245,7 +242,7 @@ def update(self, section: 'ProgressBarSection', value: int = 1) -> None:
self._maybe_update_current_section()

def update_right_side_label(self, label: Optional[str] = None) -> None:
self._current_right_side_label = f'({label})' or ''
self._current_right_side_label = f'({label})' if label else ''
self._progress_bar_update()


Expand Down
2 changes: 1 addition & 1 deletion tests/cli/commands/test_main_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def test_passing_output_option(output: str, scan_client: 'ScanClient', api_token
output = json.loads(result.output)
assert 'scan_id' in output
else:
assert 'Scan ID' in result.output
assert 'issue of type:' in result.output


@responses.activate
Expand Down