Skip to content

CM-45716 - Add rich tables with more useful information, colorful values, and clickable paths #287

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 3 commits into from
Mar 20, 2025
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
13 changes: 13 additions & 0 deletions cycode/cli/cli_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ class SeverityOption(str, Enum):
def get_member_weight(name: str) -> int:
return _SEVERITY_WEIGHTS.get(name.lower(), _SEVERITY_DEFAULT_WEIGHT)

@staticmethod
def get_member_color(name: str) -> str:
return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR)


_SEVERITY_DEFAULT_WEIGHT = -1
_SEVERITY_WEIGHTS = {
Expand All @@ -51,3 +55,12 @@ def get_member_weight(name: str) -> int:
SeverityOption.HIGH.value: 3,
SeverityOption.CRITICAL.value: 4,
}

_SEVERITY_DEFAULT_COLOR = 'white'
_SEVERITY_COLORS = {
SeverityOption.INFO.value: 'deep_sky_blue1',
SeverityOption.LOW.value: 'gold1',
SeverityOption.MEDIUM.value: 'dark_orange',
SeverityOption.HIGH.value: 'red1',
SeverityOption.CRITICAL.value: 'red3',
}
100 changes: 56 additions & 44 deletions cycode/cli/printers/tables/sca_table_printer.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List

import click
import typer

from cycode.cli.cli_types import SeverityOption
from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID
from cycode.cli.models import Detection
from cycode.cli.printers.tables.table import Table
from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidths
from cycode.cli.printers.tables.table_models import ColumnInfoBuilder
from cycode.cli.printers.tables.table_printer_base import TablePrinterBase
from cycode.cli.utils.string_utils import shortcut_dependency_paths

Expand All @@ -19,36 +19,26 @@
# Building must have strict order. Represents the order of the columns in the table (from left to right)
SEVERITY_COLUMN = column_builder.build(name='Severity')
REPOSITORY_COLUMN = column_builder.build(name='Repository')
CODE_PROJECT_COLUMN = column_builder.build(name='Code Project') # File path to manifest file
ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem')
PACKAGE_COLUMN = column_builder.build(name='Package')
CVE_COLUMNS = column_builder.build(name='CVE')
CODE_PROJECT_COLUMN = column_builder.build(name='Code Project', highlight=False) # File path to the manifest file
ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem', highlight=False)
PACKAGE_COLUMN = column_builder.build(name='Package', highlight=False)
CVE_COLUMNS = column_builder.build(name='CVE', highlight=False)
DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths')
UPGRADE_COLUMN = column_builder.build(name='Upgrade')
LICENSE_COLUMN = column_builder.build(name='License')
LICENSE_COLUMN = column_builder.build(name='License', highlight=False)
DIRECT_DEPENDENCY_COLUMN = column_builder.build(name='Direct Dependency')
DEVELOPMENT_DEPENDENCY_COLUMN = column_builder.build(name='Development Dependency')

COLUMN_WIDTHS_CONFIG: ColumnWidths = {
REPOSITORY_COLUMN: 2,
CODE_PROJECT_COLUMN: 2,
PACKAGE_COLUMN: 3,
CVE_COLUMNS: 5,
UPGRADE_COLUMN: 3,
LICENSE_COLUMN: 2,
}


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)
table.set_cols_width(COLUMN_WIDTHS_CONFIG)

for detection in self._sort_and_group_detections(detections):
self._enrich_table_with_values(table, detection)
self._enrich_table_with_values(policy_id, table, detection)

self._print_summary_issues(len(detections), self._get_title(policy_id))
self._print_table(table)
Expand Down Expand Up @@ -90,7 +80,7 @@ def _sort_and_group_detections(self, detections: List[Detection]) -> List[Detect
"""Sort detections by severity and group by repository, code project and package name.

Note:
Code Project is path to manifest file.
Code Project is path to the manifest file.

Grouping by code projects also groups by ecosystem.
Because manifest files are unique per ecosystem.
Expand All @@ -114,55 +104,77 @@ def _get_table(self, policy_id: str) -> Table:
table = Table()

if policy_id == PACKAGE_VULNERABILITY_POLICY_ID:
table.add(SEVERITY_COLUMN)
table.add(CVE_COLUMNS)
table.add(UPGRADE_COLUMN)
table.add_column(CVE_COLUMNS)
table.add_column(UPGRADE_COLUMN)
elif policy_id == LICENSE_COMPLIANCE_POLICY_ID:
table.add(LICENSE_COLUMN)
table.add_column(LICENSE_COLUMN)

if self._is_git_repository():
table.add(REPOSITORY_COLUMN)
table.add_column(REPOSITORY_COLUMN)

table.add(CODE_PROJECT_COLUMN)
table.add(ECOSYSTEM_COLUMN)
table.add(PACKAGE_COLUMN)
table.add(DIRECT_DEPENDENCY_COLUMN)
table.add(DEVELOPMENT_DEPENDENCY_COLUMN)
table.add(DEPENDENCY_PATHS_COLUMN)
table.add_column(SEVERITY_COLUMN)
table.add_column(CODE_PROJECT_COLUMN)
table.add_column(ECOSYSTEM_COLUMN)
table.add_column(PACKAGE_COLUMN)
table.add_column(DIRECT_DEPENDENCY_COLUMN)
table.add_column(DEVELOPMENT_DEPENDENCY_COLUMN)
table.add_column(DEPENDENCY_PATHS_COLUMN)

return table

@staticmethod
def _enrich_table_with_values(table: Table, detection: Detection) -> None:
def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection) -> None:
detection_details = detection.detection_details

table.set(SEVERITY_COLUMN, detection_details.get('advisory_severity'))
table.set(REPOSITORY_COLUMN, detection_details.get('repository_name'))

table.set(CODE_PROJECT_COLUMN, detection_details.get('file_name'))
table.set(ECOSYSTEM_COLUMN, detection_details.get('ecosystem'))
table.set(PACKAGE_COLUMN, detection_details.get('package_name'))
table.set(DIRECT_DEPENDENCY_COLUMN, detection_details.get('is_direct_dependency_str'))
table.set(DEVELOPMENT_DEPENDENCY_COLUMN, detection_details.get('is_dev_dependency_str'))
severity = None
if policy_id == PACKAGE_VULNERABILITY_POLICY_ID:
severity = detection_details.get('advisory_severity')
elif policy_id == LICENSE_COMPLIANCE_POLICY_ID:
severity = detection.severity

if not severity:
severity = 'N/A'

table.add_cell(SEVERITY_COLUMN, severity, SeverityOption.get_member_color(severity))

table.add_cell(REPOSITORY_COLUMN, detection_details.get('repository_name'))
table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_name'))
table.add_cell(ECOSYSTEM_COLUMN, detection_details.get('ecosystem'))
table.add_cell(PACKAGE_COLUMN, detection_details.get('package_name'))

dependency_bool_to_color = {
True: 'green',
False: 'red',
} # by default, not colored (None)
table.add_cell(
column=DIRECT_DEPENDENCY_COLUMN,
value=detection_details.get('is_direct_dependency_str'),
color=dependency_bool_to_color.get(detection_details.get('is_direct_dependency')),
)
table.add_cell(
column=DEVELOPMENT_DEPENDENCY_COLUMN,
value=detection_details.get('is_dev_dependency_str'),
color=dependency_bool_to_color.get(detection_details.get('is_dev_dependency')),
)

dependency_paths = 'N/A'
dependency_paths_raw = detection_details.get('dependency_paths')
if dependency_paths_raw:
dependency_paths = shortcut_dependency_paths(dependency_paths_raw)
table.set(DEPENDENCY_PATHS_COLUMN, dependency_paths)
table.add_cell(DEPENDENCY_PATHS_COLUMN, dependency_paths)

upgrade = ''
alert = detection_details.get('alert')
if alert and alert.get('first_patched_version'):
upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}'
table.set(UPGRADE_COLUMN, upgrade)
table.add_cell(UPGRADE_COLUMN, upgrade)

table.set(CVE_COLUMNS, detection_details.get('vulnerability_id'))
table.set(LICENSE_COLUMN, detection_details.get('license'))
table.add_cell(CVE_COLUMNS, detection_details.get('vulnerability_id'))
table.add_cell(LICENSE_COLUMN, detection_details.get('license'))

@staticmethod
def _print_summary_issues(detections_count: int, title: str) -> None:
click.echo(f'⛔ Found {detections_count} issues of type: {click.style(title, bold=True)}')
typer.echo(f'⛔ Found {detections_count} issues of type: {typer.style(title, bold=True)}')

@staticmethod
def _extract_detections_per_policy_id(
Expand Down
53 changes: 25 additions & 28 deletions cycode/cli/printers/tables/table.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,59 @@
import urllib.parse
from typing import TYPE_CHECKING, Dict, List, Optional

from texttable import Texttable
from rich.markup import escape
from rich.table import Table as RichTable

if TYPE_CHECKING:
from cycode.cli.printers.tables.table_models import ColumnInfo, ColumnWidths
from cycode.cli.printers.tables.table_models import ColumnInfo


class Table:
"""Helper class to manage columns and their values in the right order and only if the column should be presented."""

def __init__(self, column_infos: Optional[List['ColumnInfo']] = None) -> None:
self._column_widths = None

self._columns: Dict['ColumnInfo', List[str]] = {}
if column_infos:
self._columns: Dict['ColumnInfo', List[str]] = {columns: [] for columns in column_infos}
self._columns = {columns: [] for columns in column_infos}

def add(self, column: 'ColumnInfo') -> None:
def add_column(self, column: 'ColumnInfo') -> None:
self._columns[column] = []

def set(self, column: 'ColumnInfo', value: str) -> None:
def _add_cell_no_error(self, column: 'ColumnInfo', value: str) -> None:
# we push values only for existing columns what were added before
if column in self._columns:
self._columns[column].append(value)

def add_cell(self, column: 'ColumnInfo', value: str, color: Optional[str] = None) -> None:
if color:
value = f'[{color}]{value}[/{color}]'

self._add_cell_no_error(column, value)

def add_file_path_cell(self, column: 'ColumnInfo', path: str) -> None:
encoded_path = urllib.parse.quote(path)
escaped_path = escape(encoded_path)
self._add_cell_no_error(column, f'[link file://{escaped_path}]{path}')

def _get_ordered_columns(self) -> List['ColumnInfo']:
# we are sorting columns by index to make sure that columns will be printed in the right order
return sorted(self._columns, key=lambda column_info: column_info.index)

def get_columns_info(self) -> List['ColumnInfo']:
return self._get_ordered_columns()

def get_headers(self) -> List[str]:
return [header.name for header in self._get_ordered_columns()]

def get_rows(self) -> List[str]:
column_values = [self._columns[column_info] for column_info in self._get_ordered_columns()]
return list(zip(*column_values))

def set_cols_width(self, column_widths: 'ColumnWidths') -> None:
header_width_size = []
for header in self.get_columns_info():
width_multiplier = 1
if header in column_widths:
width_multiplier = column_widths[header]

header_width_size.append(len(header.name) * width_multiplier)

self._column_widths = header_width_size

def get_table(self, max_width: int = 80) -> Texttable:
table = Texttable(max_width)
table.header(self.get_headers())
def get_table(self) -> 'RichTable':
table = RichTable(expand=True, highlight=True)

for row in self.get_rows():
table.add_row(row)
for column in self.get_columns_info():
extra_args = column.column_opts if column.column_opts else {}
table.add_column(header=column.name, overflow='fold', **extra_args)

if self._column_widths:
table.set_cols_width(self._column_widths)
for raw in self.get_rows():
table.add_row(*raw)

return table
15 changes: 10 additions & 5 deletions cycode/cli/printers/tables/table_models.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
from typing import Dict, NamedTuple
from typing import Any, Dict, NamedTuple, Optional


class ColumnInfoBuilder:
def __init__(self) -> None:
self._index = 0

def build(self, name: str) -> 'ColumnInfo':
column_info = ColumnInfo(name, self._index)
def build(self, name: str, **column_opts) -> 'ColumnInfo':
column_info = ColumnInfo(name, self._index, column_opts)
self._index += 1
return column_info


class ColumnInfo(NamedTuple):
name: str
index: int # Represents the order of the columns, starting from the left
column_opts: Optional[Dict] = None

def __hash__(self) -> int:
return hash((self.name, self.index))

ColumnWidths = Dict[ColumnInfo, int]
ColumnWidthsConfig = Dict[str, ColumnWidths]
def __eq__(self, other: Any) -> bool:
if not isinstance(other, ColumnInfo):
return NotImplemented
return (self.name, self.index) == (other.name, other.index)
Loading