Skip to content

Commit dad7859

Browse files
authored
CM-46732 - Add rich output; improve text output (#295)
1 parent 0b32c0d commit dad7859

File tree

18 files changed

+540
-235
lines changed

18 files changed

+540
-235
lines changed

cycode/cli/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def app_callback(
5959
] = False,
6060
output: Annotated[
6161
OutputTypeOption, typer.Option('--output', '-o', case_sensitive=False, help='Specify the output type.')
62-
] = OutputTypeOption.TEXT,
62+
] = OutputTypeOption.RICH,
6363
user_agent: Annotated[
6464
Optional[str],
6565
typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'),

cycode/cli/apps/status/version_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66

77
def version_command(ctx: typer.Context) -> None:
88
console.print('[b yellow]This command is deprecated. Please use the "status" command instead.[/]')
9-
console.print() # print an empty line
9+
console.line()
1010
status_command(ctx)

cycode/cli/cli_types.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55

66
class OutputTypeOption(str, Enum):
7+
RICH = 'rich'
78
TEXT = 'text'
89
JSON = 'json'
910
TABLE = 'table'
@@ -55,6 +56,10 @@ def get_member_weight(name: str) -> int:
5556
def get_member_color(name: str) -> str:
5657
return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR)
5758

59+
@staticmethod
60+
def get_member_emoji(name: str) -> str:
61+
return _SEVERITY_EMOJIS.get(name.lower(), _SEVERITY_DEFAULT_EMOJI)
62+
5863
def __rich__(self) -> str:
5964
color = self.get_member_color(self.value)
6065
return f'[{color}]{self.value.upper()}[/]'
@@ -77,3 +82,12 @@ def __rich__(self) -> str:
7782
SeverityOption.HIGH.value: 'red1',
7883
SeverityOption.CRITICAL.value: 'red3',
7984
}
85+
86+
_SEVERITY_DEFAULT_EMOJI = ':white_circle:'
87+
_SEVERITY_EMOJIS = {
88+
SeverityOption.INFO.value: ':blue_circle:',
89+
SeverityOption.LOW.value: ':yellow_circle:',
90+
SeverityOption.MEDIUM.value: ':orange_circle:',
91+
SeverityOption.HIGH.value: ':heavy_large_circle:',
92+
SeverityOption.CRITICAL.value: ':red_circle:',
93+
}

cycode/cli/console.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import os
2-
from typing import Optional
2+
from typing import TYPE_CHECKING, Optional
33

4-
from rich.console import Console
4+
from rich.console import Console, RenderResult
5+
from rich.markdown import Heading, Markdown
6+
from rich.text import Text
7+
8+
if TYPE_CHECKING:
9+
from rich.console import ConsoleOptions
510

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

4651
# when we could not detect it, use dark theme as most terminals are dark
4752
_SYNTAX_HIGHLIGHT_THEME = _SYNTAX_HIGHLIGHT_LIGHT_THEME if is_dark_console() is False else _SYNTAX_HIGHLIGHT_DARK_THEME
53+
54+
55+
class CycodeHeading(Heading):
56+
"""Custom Rich Heading for Markdown.
57+
58+
Changes:
59+
- remove justify to 'center'
60+
- remove the box for h1
61+
"""
62+
63+
def __rich_console__(self, console: 'Console', options: 'ConsoleOptions') -> RenderResult:
64+
if self.tag == 'h2':
65+
yield Text('')
66+
yield self.text
67+
68+
69+
Markdown.elements['heading_open'] = CycodeHeading

cycode/cli/consts.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
APP_NAME = 'CycodeCLI'
33
CLI_CONTEXT_SETTINGS = {'terminal_width': 10**9, 'max_content_width': 10**9, 'help_option_names': ['-h', '--help']}
44

5-
PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre_commit'
6-
PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre_receive'
7-
COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit_history'
5+
PRE_COMMIT_COMMAND_SCAN_TYPE = 'pre-commit'
6+
PRE_COMMIT_COMMAND_SCAN_TYPE_OLD = 'pre_commit'
7+
PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre-receive'
8+
PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD = 'pre_receive'
9+
COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit-history'
10+
COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD = 'commit_history'
811

912
SECRET_SCAN_TYPE = 'secret' # noqa: S105
1013
IAC_SCAN_TYPE = 'iac'
@@ -105,7 +108,12 @@
105108

106109
COMMIT_RANGE_SCAN_SUPPORTED_SCAN_TYPES = [SECRET_SCAN_TYPE, SCA_SCAN_TYPE]
107110

108-
COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [PRE_RECEIVE_COMMAND_SCAN_TYPE, COMMIT_HISTORY_COMMAND_SCAN_TYPE]
111+
COMMIT_RANGE_BASED_COMMAND_SCAN_TYPES = [
112+
PRE_RECEIVE_COMMAND_SCAN_TYPE,
113+
PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD,
114+
COMMIT_HISTORY_COMMAND_SCAN_TYPE,
115+
COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD,
116+
]
109117

110118
DEFAULT_CYCODE_DOMAIN = 'cycode.com'
111119
DEFAULT_CYCODE_API_URL = f'https://api.{DEFAULT_CYCODE_DOMAIN}'

cycode/cli/printers/console_printer.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from cycode.cli.exceptions.custom_exceptions import CycodeError
66
from cycode.cli.models import CliError, CliResult
77
from cycode.cli.printers.json_printer import JsonPrinter
8+
from cycode.cli.printers.rich_printer import RichPrinter
89
from cycode.cli.printers.tables.sca_table_printer import ScaTablePrinter
910
from cycode.cli.printers.tables.table_printer import TablePrinter
1011
from cycode.cli.printers.text_printer import TextPrinter
@@ -16,12 +17,14 @@
1617

1718
class ConsolePrinter:
1819
_AVAILABLE_PRINTERS: ClassVar[Dict[str, Type['PrinterBase']]] = {
20+
'rich': RichPrinter,
1921
'text': TextPrinter,
2022
'json': JsonPrinter,
2123
'table': TablePrinter,
2224
# overrides
2325
'table_sca': ScaTablePrinter,
2426
'text_sca': ScaTablePrinter,
27+
'rich_sca': ScaTablePrinter,
2528
}
2629

2730
def __init__(self, ctx: typer.Context) -> None:
@@ -74,3 +77,7 @@ def is_table_printer(self) -> bool:
7477
@property
7578
def is_text_printer(self) -> bool:
7679
return self._printer_class == TextPrinter
80+
81+
@property
82+
def is_rich_printer(self) -> bool:
83+
return self._printer_class == RichPrinter
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from pathlib import Path
2+
from typing import TYPE_CHECKING, Dict, List, Optional
3+
4+
from rich.console import Group
5+
from rich.panel import Panel
6+
from rich.table import Table
7+
from rich.text import Text
8+
9+
from cycode.cli import consts
10+
from cycode.cli.cli_types import SeverityOption
11+
from cycode.cli.console import console
12+
from cycode.cli.printers.text_printer import TextPrinter
13+
from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax
14+
from cycode.cli.printers.utils.detection_data import get_detection_title
15+
from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result
16+
from cycode.cli.printers.utils.rich_helpers import get_columns_in_1_to_3_ratio, get_markdown_panel, get_panel
17+
18+
if TYPE_CHECKING:
19+
from cycode.cli.models import CliError, Detection, Document, LocalScanResult
20+
21+
22+
class RichPrinter(TextPrinter):
23+
def print_scan_results(
24+
self, local_scan_results: List['LocalScanResult'], errors: Optional[Dict[str, 'CliError']] = None
25+
) -> None:
26+
if not errors and all(result.issue_detected == 0 for result in local_scan_results):
27+
console.print(self.NO_DETECTIONS_MESSAGE)
28+
return
29+
30+
current_file = None
31+
detections, _ = sort_and_group_detections_from_scan_result(local_scan_results)
32+
detections_count = len(detections)
33+
for detection_number, (detection, document) in enumerate(detections, start=1):
34+
if current_file != document.path:
35+
current_file = document.path
36+
self._print_file_header(current_file)
37+
38+
self._print_violation_card(
39+
document,
40+
detection,
41+
detection_number,
42+
detections_count,
43+
)
44+
45+
self.print_report_urls_and_errors(local_scan_results, errors)
46+
47+
@staticmethod
48+
def _print_file_header(file_path: str) -> None:
49+
clickable_path = f'[link=file://{file_path}]{file_path}[/link]'
50+
file_header = Panel(
51+
Text.from_markup(f'[b purple3]:file_folder: File: {clickable_path}[/]', justify='center'),
52+
border_style='dim',
53+
)
54+
console.print(file_header)
55+
56+
def _get_details_table(self, detection: 'Detection') -> Table:
57+
details_table = Table(show_header=False, box=None, padding=(0, 1))
58+
59+
details_table.add_column('Key', style='dim')
60+
details_table.add_column('Value', style='', overflow='fold')
61+
62+
severity = detection.severity if detection.severity else 'N/A'
63+
severity_icon = SeverityOption.get_member_emoji(severity.lower())
64+
details_table.add_row('Severity', f'{severity_icon} {SeverityOption(severity).__rich__()}')
65+
66+
detection_details = detection.detection_details
67+
path = Path(detection_details.get('file_name', ''))
68+
details_table.add_row('In file', path.name) # it is name already except for IaC :)
69+
70+
# we do not allow using rich output with SCA; SCA designed to be used with table output
71+
if self.scan_type == consts.IAC_SCAN_TYPE:
72+
details_table.add_row('IaC Provider', detection_details.get('infra_provider'))
73+
elif self.scan_type == consts.SECRET_SCAN_TYPE:
74+
details_table.add_row('Secret SHA', detection_details.get('sha512'))
75+
elif self.scan_type == consts.SAST_SCAN_TYPE:
76+
details_table.add_row('Subcategory', detection_details.get('category'))
77+
details_table.add_row('Language', ', '.join(detection_details.get('languages', [])))
78+
79+
engine_id_to_display_name = {
80+
'5db84696-88dc-11ec-a8a3-0242ac120002': 'Semgrep OSS (Orchestrated by Cycode)',
81+
'560a0abd-d7da-4e6d-a3f1-0ed74895295c': 'Bearer (Powered by Cycode)',
82+
}
83+
engine_id = detection.detection_details.get('external_scanner_id')
84+
details_table.add_row('Security Tool', engine_id_to_display_name.get(engine_id, 'N/A'))
85+
86+
details_table.add_row('Rule ID', detection.detection_rule_id)
87+
88+
return details_table
89+
90+
def _print_violation_card(
91+
self, document: 'Document', detection: 'Detection', detection_number: int, detections_count: int
92+
) -> None:
93+
details_table = self._get_details_table(detection)
94+
details_panel = get_panel(
95+
details_table,
96+
title=':mag: Details',
97+
)
98+
99+
code_snippet_panel = get_panel(
100+
get_code_snippet_syntax(
101+
self.scan_type,
102+
self.command_scan_type,
103+
detection,
104+
document,
105+
obfuscate=not self.show_secret,
106+
),
107+
title=':computer: Code Snippet',
108+
)
109+
110+
guidelines_panel = None
111+
guidelines = detection.detection_details.get('remediation_guidelines')
112+
if guidelines:
113+
guidelines_panel = get_markdown_panel(
114+
guidelines,
115+
title=':clipboard: Cycode Guidelines',
116+
)
117+
118+
custom_guidelines_panel = None
119+
custom_guidelines = detection.detection_details.get('custom_remediation_guidelines')
120+
if custom_guidelines:
121+
custom_guidelines_panel = get_markdown_panel(
122+
custom_guidelines,
123+
title=':office: Company Guidelines',
124+
)
125+
126+
navigation = Text(f'Violation {detection_number} of {detections_count}', style='dim', justify='right')
127+
128+
renderables = [navigation, get_columns_in_1_to_3_ratio(details_panel, code_snippet_panel)]
129+
if guidelines_panel:
130+
renderables.append(guidelines_panel)
131+
if custom_guidelines_panel:
132+
renderables.append(custom_guidelines_panel)
133+
134+
violation_card_panel = Panel(
135+
Group(*renderables),
136+
title=get_detection_title(self.scan_type, detection),
137+
border_style=SeverityOption.get_member_color(detection.severity),
138+
title_align='center',
139+
)
140+
141+
console.print(violation_card_panel)

cycode/cli/printers/tables/sca_table_printer.py

Lines changed: 3 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections import defaultdict
2-
from typing import TYPE_CHECKING, Dict, List, Set, Tuple
2+
from typing import TYPE_CHECKING, Dict, List
33

44
from cycode.cli.cli_types import SeverityOption
55
from cycode.cli.console import console
@@ -8,6 +8,7 @@
88
from cycode.cli.printers.tables.table import Table
99
from cycode.cli.printers.tables.table_models import ColumnInfoBuilder
1010
from cycode.cli.printers.tables.table_printer_base import TablePrinterBase
11+
from cycode.cli.printers.utils.detection_ordering.sca_ordering import sort_and_group_detections
1112
from cycode.cli.utils.string_utils import shortcut_dependency_paths
1213

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

39-
resulting_detections, group_separator_indexes = self._sort_and_group_detections(detections)
40+
resulting_detections, group_separator_indexes = sort_and_group_detections(detections)
4041
for detection in resulting_detections:
4142
self._enrich_table_with_values(policy_id, table, detection)
4243

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

5758
return 'Unknown'
5859

59-
@staticmethod
60-
def __group_by(detections: List[Detection], details_field_name: str) -> Dict[str, List[Detection]]:
61-
grouped = defaultdict(list)
62-
for detection in detections:
63-
grouped[detection.detection_details.get(details_field_name)].append(detection)
64-
return grouped
65-
66-
@staticmethod
67-
def __severity_sort_key(detection: Detection) -> int:
68-
severity = detection.detection_details.get('advisory_severity', 'unknown')
69-
return SeverityOption.get_member_weight(severity)
70-
71-
def _sort_detections_by_severity(self, detections: List[Detection]) -> List[Detection]:
72-
return sorted(detections, key=self.__severity_sort_key, reverse=True)
73-
74-
@staticmethod
75-
def __package_sort_key(detection: Detection) -> int:
76-
return detection.detection_details.get('package_name')
77-
78-
def _sort_detections_by_package(self, detections: List[Detection]) -> List[Detection]:
79-
return sorted(detections, key=self.__package_sort_key)
80-
81-
def _sort_and_group_detections(self, detections: List[Detection]) -> Tuple[List[Detection], Set[int]]:
82-
"""Sort detections by severity and group by repository, code project and package name.
83-
84-
Note:
85-
Code Project is path to the manifest file.
86-
87-
Grouping by code projects also groups by ecosystem.
88-
Because manifest files are unique per ecosystem.
89-
"""
90-
resulting_detections = []
91-
group_separator_indexes = set()
92-
93-
# we sort detections by package name to make persist output order
94-
sorted_detections = self._sort_detections_by_package(detections)
95-
96-
grouped_by_repository = self.__group_by(sorted_detections, 'repository_name')
97-
for repository_group in grouped_by_repository.values():
98-
grouped_by_code_project = self.__group_by(repository_group, 'file_name')
99-
for code_project_group in grouped_by_code_project.values():
100-
grouped_by_package = self.__group_by(code_project_group, 'package_name')
101-
for package_group in grouped_by_package.values():
102-
group_separator_indexes.add(len(resulting_detections) - 1) # indexing starts from 0
103-
resulting_detections.extend(self._sort_detections_by_severity(package_group))
104-
105-
return resulting_detections, group_separator_indexes
106-
10760
def _get_table(self, policy_id: str) -> Table:
10861
table = Table()
10962

0 commit comments

Comments
 (0)