Skip to content
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
2 changes: 1 addition & 1 deletion cycode/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def app_callback(
] = False,
output: Annotated[
OutputTypeOption, typer.Option('--output', '-o', case_sensitive=False, help='Specify the output type.')
] = OutputTypeOption.TEXT,
] = OutputTypeOption.RICH,
user_agent: Annotated[
Optional[str],
typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'),
Expand Down
2 changes: 1 addition & 1 deletion cycode/cli/apps/status/version_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@

def version_command(ctx: typer.Context) -> None:
console.print('[b yellow]This command is deprecated. Please use the "status" command instead.[/]')
console.print() # print an empty line
console.line()
status_command(ctx)
14 changes: 14 additions & 0 deletions cycode/cli/cli_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@


class OutputTypeOption(str, Enum):
RICH = 'rich'
TEXT = 'text'
JSON = 'json'
TABLE = 'table'
Expand Down Expand Up @@ -55,6 +56,10 @@ def get_member_weight(name: str) -> int:
def get_member_color(name: str) -> str:
return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR)

@staticmethod
def get_member_emoji(name: str) -> str:
return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI)

def __rich__(self) -> str:
color = self.get_member_color(self.value)
return f'[{color}]{self.value.upper()}[/]'
Expand All @@ -77,3 +82,12 @@ def __rich__(self) -> str:
SeverityOption.HIGH.value: 'red1',
SeverityOption.CRITICAL.value: 'red3',
}

_SEVERITY_DEFAULT_EMOJI = ':white_circle:'
_SEVERITY_EMOJIS = {
SeverityOption.INFO.value: ':blue_circle:',
SeverityOption.LOW.value: ':yellow_circle:',
SeverityOption.MEDIUM.value: ':orange_circle:',
SeverityOption.HIGH.value: ':heavy_large_circle:',
SeverityOption.CRITICAL.value: ':red_circle:',
}
26 changes: 24 additions & 2 deletions cycode/cli/console.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import os
from typing import Optional
from typing import TYPE_CHECKING, Optional

from rich.console import Console
from rich.console import Console, RenderResult
from rich.markdown import Heading, Markdown
from rich.text import Text

if TYPE_CHECKING:
from rich.console import ConsoleOptions

console_out = Console()
console_err = Console(stderr=True)
Expand Down Expand Up @@ -45,3 +50,20 @@ def is_dark_console() -> Optional[bool]:

# when we could not detect it, use dark theme as most terminals are dark
_SYNTAX_HIGHLIGHT_THEME = _SYNTAX_HIGHLIGHT_LIGHT_THEME if is_dark_console() is False else _SYNTAX_HIGHLIGHT_DARK_THEME


class CycodeHeading(Heading):
"""Custom Rich Heading for Markdown.

Changes:
- remove justify to 'center'
- remove the box for h1
"""

def __rich_console__(self, console: 'Console', options: 'ConsoleOptions') -> RenderResult:
if self.tag == 'h2':
yield Text('')
yield self.text


Markdown.elements['heading_open'] = CycodeHeading
16 changes: 12 additions & 4 deletions cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
APP_NAME = 'CycodeCLI'
CLI_CONTEXT_SETTINGS = {'terminal_width': 10**9, 'max_content_width': 10**9, 'help_option_names': ['-h', '--help']}

PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre_commit'
PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre_receive'
COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit_history'
PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre-commit'
PRE_COMMIT_COMMAND_SCAN_TYPE_OLD = 'pre_commit'
PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre-receive'
PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD = 'pre_receive'
COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit-history'
COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD = 'commit_history'

SECRET_SCAN_TYPE = 'secret' # noqa: S105
IAC_SCAN_TYPE = 'iac'
Expand Down Expand Up @@ -105,7 +108,12 @@

COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE]

COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [PRE_RECEIVE_COMMAND_SCAN_TYPE, COMMIT_HISTORY_COMMAND_SCAN_TYPE]
COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [
PRE_RECEIVE_COMMAND_SCAN_TYPE,
PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD,
COMMIT_HISTORY_COMMAND_SCAN_TYPE,
COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD,
]

DEFAULT_CYCODE_DOMAIN = 'cycode.com'
DEFAULT_CYCODE_API_URL = f'https://api.{DEFAULT_CYCODE_DOMAIN}'
Expand Down
7 changes: 7 additions & 0 deletions cycode/cli/printers/console_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from cycode.cli.exceptions.custom_exceptions import CycodeError
from cycode.cli.models import CliError, CliResult
from cycode.cli.printers.json_printer import JsonPrinter
from cycode.cli.printers.rich_printer import RichPrinter
from cycode.cli.printers.tables.sca_table_printer import ScaTablePrinter
from cycode.cli.printers.tables.table_printer import TablePrinter
from cycode.cli.printers.text_printer import TextPrinter
Expand All @@ -16,12 +17,14 @@

class ConsolePrinter:
_AVAILABLE_PRINTERS: ClassVar[Dict[str, Type['PrinterBase']]] = {
'rich': RichPrinter,
'text': TextPrinter,
'json': JsonPrinter,
'table': TablePrinter,
# overrides
'table_sca': ScaTablePrinter,
'text_sca': ScaTablePrinter,
'rich_sca': ScaTablePrinter,
}

def __init__(self, ctx: typer.Context) -> None:
Expand Down Expand Up @@ -74,3 +77,7 @@ def is_table_printer(self) -> bool:
@property
def is_text_printer(self) -> bool:
return self._printer_class == TextPrinter

@property
def is_rich_printer(self) -> bool:
return self._printer_class == RichPrinter
141 changes: 141 additions & 0 deletions cycode/cli/printers/rich_printer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from pathlib import Path
from typing import TYPE_CHECKING, Dict, List, Optional

from rich.console import Group
from rich.panel import Panel
from rich.table import Table
from rich.text import Text

from cycode.cli import consts
from cycode.cli.cli_types import SeverityOption
from cycode.cli.console import console
from cycode.cli.printers.text_printer import TextPrinter
from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax
from cycode.cli.printers.utils.detection_data import get_detection_title
from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result
from cycode.cli.printers.utils.rich_helpers import get_columns_in_1_to_3_ratio, get_markdown_panel, get_panel

if TYPE_CHECKING:
from cycode.cli.models import CliError, Detection, Document, LocalScanResult


class RichPrinter(TextPrinter):
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):
console.print(self.NO_DETECTIONS_MESSAGE)
return

current_file = None
detections, _ = sort_and_group_detections_from_scan_result(local_scan_results)
detections_count = len(detections)
for detection_number, (detection, document) in enumerate(detections, start=1):
if current_file != document.path:
current_file = document.path
self._print_file_header(current_file)

self._print_violation_card(
document,
detection,
detection_number,
detections_count,
)

self.print_report_urls_and_errors(local_scan_results, errors)

@staticmethod
def _print_file_header(file_path: str) -> None:
clickable_path = f'[link=file://{file_path}]{file_path}[/link]'
file_header = Panel(
Text.from_markup(f'[b purple3]:file_folder: File: {clickable_path}[/]', justify='center'),
border_style='dim',
)
console.print(file_header)

def _get_details_table(self, detection: 'Detection') -> Table:
details_table = Table(show_header=False, box=None, padding=(0, 1))

details_table.add_column('Key', style='dim')
details_table.add_column('Value', style='', overflow='fold')

severity = detection.severity if detection.severity else 'N/A'
severity_icon = SeverityOption.get_member_emoji(severity.lower())
details_table.add_row('Severity', f'{severity_icon} {SeverityOption(severity).__rich__()}')

detection_details = detection.detection_details
path = Path(detection_details.get('file_name', ''))
details_table.add_row('In file', path.name) # it is name already except for IaC :)

# we do not allow using rich output with SCA; SCA designed to be used with table output
if self.scan_type == consts.IAC_SCAN_TYPE:
details_table.add_row('IaC Provider', detection_details.get('infra_provider'))
elif self.scan_type == consts.SECRET_SCAN_TYPE:
details_table.add_row('Secret SHA', detection_details.get('sha512'))
elif self.scan_type == consts.SAST_SCAN_TYPE:
details_table.add_row('Subcategory', detection_details.get('category'))
details_table.add_row('Language', ', '.join(detection_details.get('languages', [])))

engine_id_to_display_name = {
'5db84696-88dc-11ec-a8a3-0242ac120002': 'Semgrep OSS (Orchestrated by Cycode)',
'560a0abd-d7da-4e6d-a3f1-0ed74895295c': 'Bearer (Powered by Cycode)',
}
engine_id = detection.detection_details.get('external_scanner_id')
details_table.add_row('Security Tool', engine_id_to_display_name.get(engine_id, 'N/A'))

details_table.add_row('Rule ID', detection.detection_rule_id)

return details_table

def _print_violation_card(
self, document: 'Document', detection: 'Detection', detection_number: int, detections_count: int
) -> None:
details_table = self._get_details_table(detection)
details_panel = get_panel(
details_table,
title=':mag: Details',
)

code_snippet_panel = get_panel(
get_code_snippet_syntax(
self.scan_type,
self.command_scan_type,
detection,
document,
obfuscate=not self.show_secret,
),
title=':computer: Code Snippet',
)

guidelines_panel = None
guidelines = detection.detection_details.get('remediation_guidelines')
if guidelines:
guidelines_panel = get_markdown_panel(
guidelines,
title=':clipboard: Cycode Guidelines',
)

custom_guidelines_panel = None
custom_guidelines = detection.detection_details.get('custom_remediation_guidelines')
if custom_guidelines:
custom_guidelines_panel = get_markdown_panel(
custom_guidelines,
title=':office: Company Guidelines',
)

navigation = Text(f'Violation {detection_number} of {detections_count}', style='dim', justify='right')

renderables = [navigation, get_columns_in_1_to_3_ratio(details_panel, code_snippet_panel)]
if guidelines_panel:
renderables.append(guidelines_panel)
if custom_guidelines_panel:
renderables.append(custom_guidelines_panel)

violation_card_panel = Panel(
Group(*renderables),
title=get_detection_title(self.scan_type, detection),
border_style=SeverityOption.get_member_color(detection.severity),
title_align='center',
)

console.print(violation_card_panel)
53 changes: 3 additions & 50 deletions cycode/cli/printers/tables/sca_table_printer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Set, Tuple
from typing import TYPE_CHECKING, Dict, List

from cycode.cli.cli_types import SeverityOption
from cycode.cli.console import console
Expand All @@ -8,6 +8,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.detection_ordering.sca_ordering import sort_and_group_detections
from cycode.cli.utils.string_utils import shortcut_dependency_paths

if TYPE_CHECKING:
Expand Down Expand Up @@ -36,7 +37,7 @@ def _print_results(self, local_scan_results: List['LocalScanResult']) -> None:
for policy_id, detections in detections_per_policy_id.items():
table = self._get_table(policy_id)

resulting_detections, group_separator_indexes = self._sort_and_group_detections(detections)
resulting_detections, group_separator_indexes = sort_and_group_detections(detections)
for detection in resulting_detections:
self._enrich_table_with_values(policy_id, table, detection)

Expand All @@ -56,54 +57,6 @@ def _get_title(policy_id: str) -> str:

return 'Unknown'

@staticmethod
def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str, List[Detection]]:
grouped = defaultdict(list)
for detection in detections:
grouped[detection.detection_details.get(details_field_name)].append(detection)
return grouped

@staticmethod
def __severity_sort_key(detection: Detection) -> int:
severity = detection.detection_details.get('advisory_severity', 'unknown')
return SeverityOption.get_member_weight(severity)

def _sort_detections_by_severity(self, detections: List[Detection]) -> List[Detection]:
return sorted(detections, key=self.__severity_sort_key, reverse=True)

@staticmethod
def __package_sort_key(detection: Detection) -> int:
return detection.detection_details.get('package_name')

def _sort_detections_by_package(self, detections: List[Detection]) -> List[Detection]:
return sorted(detections, key=self.__package_sort_key)

def _sort_and_group_detections(self, detections: List[Detection]) -> Tuple[List[Detection], Set[int]]:
"""Sort detections by severity and group by repository, code project and package name.

Note:
Code Project is path to the manifest file.

Grouping by code projects also groups by ecosystem.
Because manifest files are unique per ecosystem.
"""
resulting_detections = []
group_separator_indexes = set()

# we sort detections by package name to make persist output order
sorted_detections = self._sort_detections_by_package(detections)

grouped_by_repository = self.__group_by(sorted_detections, 'repository_name')
for repository_group in grouped_by_repository.values():
grouped_by_code_project = self.__group_by(repository_group, 'file_name')
for code_project_group in grouped_by_code_project.values():
grouped_by_package = self.__group_by(code_project_group, 'package_name')
for package_group in grouped_by_package.values():
group_separator_indexes.add(len(resulting_detections) - 1) # indexing starts from 0
resulting_detections.extend(self._sort_detections_by_severity(package_group))

return resulting_detections, group_separator_indexes

def _get_table(self, policy_id: str) -> Table:
table = Table()

Expand Down
Loading